Merge remote-tracking branch 'kuba/update-challenges' into update-challenges

This commit is contained in:
Brad Warren
2015-08-04 16:57:53 -07:00
34 changed files with 697 additions and 769 deletions
+220 -152
View File
@@ -1,13 +1,9 @@
"""ACME Identifier Validation Challenges.""" """ACME Identifier Validation Challenges."""
import binascii
import functools import functools
import hashlib import hashlib
import logging import logging
import os
import socket import socket
from cryptography.hazmat.backends import default_backend
from cryptography import x509
import OpenSSL import OpenSSL
import requests import requests
@@ -54,43 +50,45 @@ 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."""
# TODO: acme-spec doesn't specify token as base64-encoded value
token = jose.Field(
"token", encoder=jose.encode_b64jose, decoder=functools.partial(
jose.decode_b64jose, size=TOKEN_SIZE, minimum=True))
@property
def good_token(self): # XXX: @token.decoder
"""Is `token` good?
.. todo:: acme-spec wants "It MUST NOT contain any non-ASCII
characters", but it should also warrant that it doesn't
contain ".." or "/"...
"""
# TODO: check that path combined with uri does not go above
# URI_ROOT_PATH!
return b'..' not in self.token and b'/' not in self.token
@ChallengeResponse.register @ChallengeResponse.register
class SimpleHTTPResponse(ChallengeResponse): class SimpleHTTPResponse(ChallengeResponse):
"""ACME "simpleHttp" challenge response. """ACME "simpleHttp" challenge response.
:ivar unicode path: :ivar bool tls:
:ivar unicode tls:
""" """
typ = "simpleHttp" typ = "simpleHttp"
path = jose.Field("path")
tls = jose.Field("tls", default=True, omitempty=True) tls = jose.Field("tls", default=True, omitempty=True)
URI_ROOT_PATH = ".well-known/acme-challenge" URI_ROOT_PATH = ".well-known/acme-challenge"
"""URI root path for the server provisioned resource.""" """URI root path for the server provisioned resource."""
_URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{path}" _URI_TEMPLATE = "{scheme}://{domain}/" + URI_ROOT_PATH + "/{token}"
MAX_PATH_LEN = 25 CONTENT_TYPE = "application/jose+json"
"""Maximum allowed `path` length."""
CONTENT_TYPE = "text/plain"
@property
def good_path(self):
"""Is `path` good?
.. todo:: acme-spec: "The value MUST be comprised entirely of
characters from the URL-safe alphabet for Base64 encoding
[RFC4648]", base64.b64decode ignores those characters
"""
# TODO: check that path combined with uri does not go above
# URI_ROOT_PATH!
return len(self.path) <= 25
@property @property
def scheme(self): def scheme(self):
@@ -102,19 +100,73 @@ class SimpleHTTPResponse(ChallengeResponse):
"""Port that the ACME client should be listening for validation.""" """Port that the ACME client should be listening for validation."""
return 443 if self.tls else 80 return 443 if self.tls else 80
def uri(self, domain): def uri(self, domain, chall):
"""Create an URI to the provisioned resource. """Create an URI to the provisioned resource.
Forms an URI to the HTTPS server provisioned resource Forms an URI to the HTTPS server provisioned resource
(containing :attr:`~SimpleHTTP.token`). (containing :attr:`~SimpleHTTP.token`).
:param unicode domain: Domain name being verified. :param unicode domain: Domain name being verified.
:param challenges.SimpleHTTP chall:
""" """
return self._URI_TEMPLATE.format( return self._URI_TEMPLATE.format(
scheme=self.scheme, domain=domain, path=self.path) scheme=self.scheme, domain=domain, token=chall.encode("token"))
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, 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(
sort_keys=True).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.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
@@ -123,6 +175,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``
@@ -138,76 +200,67 @@ class SimpleHTTPResponse(ChallengeResponse):
"Using non-standard port for SimpleHTTP verification: %s", port) "Using non-standard port for SimpleHTTP verification: %s", port)
domain += ":{0}".format(port) domain += ":{0}".format(port)
uri = self.uri(domain) uri = self.uri(domain, chall)
logger.debug("Verifying %s at %s...", chall.typ, uri) logger.debug("Verifying %s at %s...", chall.typ, uri)
try: try:
http_response = requests.get(uri, verify=False) http_response = requests.get(uri, verify=False)
except requests.exceptions.RequestException as error: except requests.exceptions.RequestException as error:
logger.error("Unable to reach %s: %s", uri, error) logger.error("Unable to reach %s: %s", uri, error)
return False return False
logger.debug( logger.debug("Received %s: %s. Headers: %s", http_response,
"Received %s. Headers: %s", http_response, http_response.headers) http_response.text, 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"]
# If the "tls" field is not included in the response, then
# validation object MUST have its "tls" field set to "true".
tls = jose.Field("tls", omitempty=False)
@Challenge.register @Challenge.register
class DVSNI(DVChallenge): class DVSNI(DVChallenge):
"""ACME "dvsni" challenge. """ACME "dvsni" challenge.
:ivar bytes r: Random data, **not** base64-encoded. :ivar bytes token: Random data, **not** base64-encoded.
:ivar bytes nonce: Random data, **not** hex-encoded.
""" """
typ = "dvsni" typ = "dvsni"
DOMAIN_SUFFIX = b".acme.invalid"
"""Domain name suffix."""
R_SIZE = 32
"""Required size of the :attr:`r` in bytes."""
NONCE_SIZE = 16
"""Required size of the :attr:`nonce` in bytes."""
PORT = 443 PORT = 443
"""Port to perform DVSNI challenge.""" """Port to perform DVSNI challenge."""
r = jose.Field("r", encoder=jose.encode_b64jose, # pylint: disable=invalid-name TOKEN_SIZE = 128 / 8 # Based on the entropy value from the spec
decoder=functools.partial(jose.decode_b64jose, size=R_SIZE)) """Minimum size of the :attr:`token` in bytes."""
nonce = jose.Field("nonce", encoder=jose.encode_hex16,
decoder=functools.partial(functools.partial(
jose.decode_hex16, size=NONCE_SIZE)))
@property token = jose.Field(
def nonce_domain(self): "token", encoder=jose.encode_b64jose, decoder=functools.partial(
"""Domain name used in SNI. jose.decode_b64jose, size=TOKEN_SIZE, minimum=True))
:rtype: bytes def gen_response(self, account_key, alg=jose.RS256, **kwargs):
"""Generate response.
:param .JWK account_key: Private account key.
:rtype: .DVSNIResponse
""" """
return binascii.hexlify(self.nonce) + self.DOMAIN_SUFFIX return DVSNIResponse(validation=jose.JWS.sign(
payload=self.json_dumps(sort_keys=True).encode('utf-8'),
def probe_cert(self, domain, **kwargs): key=account_key, alg=alg, **kwargs))
"""Probe DVSNI challenge certificate."""
host = socket.gethostbyname(domain)
logging.debug('%s resolved to %s', domain, host)
kwargs.setdefault("host", host)
kwargs.setdefault("port", self.PORT)
kwargs["name"] = self.nonce_domain
# TODO: try different methods?
# pylint: disable=protected-access
return crypto_util._probe_sni(**kwargs)
@ChallengeResponse.register @ChallengeResponse.register
@@ -219,105 +272,137 @@ class DVSNIResponse(ChallengeResponse):
""" """
typ = "dvsni" typ = "dvsni"
DOMAIN_SUFFIX = DVSNI.DOMAIN_SUFFIX DOMAIN_SUFFIX = b".acme.invalid"
"""Domain name suffix.""" """Domain name suffix."""
S_SIZE = 32 PORT = DVSNI.PORT
"""Required size of the :attr:`s` in bytes.""" """Port to perform DVSNI challenge."""
s = jose.Field("s", encoder=jose.encode_b64jose, # pylint: disable=invalid-name validation = jose.Field("validation", decoder=jose.JWS.from_json)
decoder=functools.partial(jose.decode_b64jose, size=S_SIZE))
def __init__(self, s=None, *args, **kwargs): @property
s = os.urandom(self.S_SIZE) if s is None else s def z(self): # pylint: disable=invalid-name
super(DVSNIResponse, self).__init__(s=s, *args, **kwargs) """The ``z`` parameter.
def z(self, chall): # pylint: disable=invalid-name
"""Compute the parameter ``z``.
:param challenge: Corresponding challenge.
:type challenge: :class:`DVSNI`
:rtype: bytes :rtype: bytes
""" """
z = hashlib.new("sha256") # pylint: disable=invalid-name # Instance of 'Field' has no 'signature' member
z.update(chall.r) # pylint: disable=no-member
z.update(self.s) return hashlib.sha256(self.validation.signature.encode(
return z.hexdigest().encode() "signature").encode("utf-8")).hexdigest().encode()
def z_domain(self, chall): @property
def z_domain(self):
"""Domain name for certificate subjectAltName. """Domain name for certificate subjectAltName.
:rtype bytes: :rtype: bytes
""" """
return self.z(chall) + self.DOMAIN_SUFFIX z = self.z # pylint: disable=invalid-name
return z[:32] + b'.' + z[32:] + self.DOMAIN_SUFFIX
def gen_cert(self, chall, domain, key): @property
def chall(self):
"""Get challenge encoded in the `validation` payload.
:rtype: DVSNI
"""
# pylint: disable=no-member
return DVSNI.json_loads(self.validation.payload.decode('utf-8'))
def gen_cert(self, key=None, bits=2048):
"""Generate DVSNI certificate. """Generate DVSNI certificate.
:param .DVSNI chall: Corresponding challenge. :param OpenSSL.crypto.PKey key: Optional private key used in
:param unicode domain: certificate generation. If not provided (``None``), then
:param OpenSSL.crypto.PKey fresh key will be generated.
:param int bits: Number of bits for newly generated key.
:rtype: `tuple` of `OpenSSL.crypto.X509` and
`OpenSSL.crypto.PKey`
""" """
if key is None:
key = OpenSSL.crypto.PKey()
key.generate_key(OpenSSL.crypto.TYPE_RSA, bits)
return crypto_util.gen_ss_cert(key, [ return crypto_util.gen_ss_cert(key, [
domain, chall.nonce_domain.decode(), self.z_domain(chall).decode()]) # z_domain is too big to fit into CN, hence first dummy domain
'dummy', self.z_domain.decode()], force_san=True), key
def simple_verify(self, chall, domain, public_key, **kwargs): def probe_cert(self, domain, **kwargs):
"""Probe DVSNI challenge certificate.
:param unicode domain:
"""
host = socket.gethostbyname(domain)
logging.debug('%s resolved to %s', domain, host)
kwargs.setdefault("host", host)
kwargs.setdefault("port", self.PORT)
kwargs["name"] = self.z_domain
# TODO: try different methods?
# pylint: disable=protected-access
return crypto_util._probe_sni(**kwargs)
def verify_cert(self, cert):
"""Verify DVSNI challenge certificate."""
# pylint: disable=protected-access
sans = crypto_util._pyopenssl_cert_or_req_san(cert)
logging.debug('Certificate %s. SANs: %s', cert.digest('sha1'), sans)
return self.z_domain.decode() in sans
def simple_verify(self, chall, domain, account_public_key,
cert=None, **kwargs):
"""Simple verify. """Simple verify.
Probes DVSNI certificate and checks it using `verify_cert`; Verify ``validation`` using ``account_public_key``, optionally
hence all arguments documented in `verify_cert`. probe DVSNI certificate and check using `verify_cert`.
"""
try:
cert = chall.probe_cert(domain=domain, **kwargs)
except errors.Error as error:
logger.debug(error, exc_info=True)
return False
return self.verify_cert(chall, domain, public_key, cert)
def verify_cert(self, chall, domain, public_key, cert):
"""Verify DVSNI certificate.
:param .challenges.DVSNI chall: Corresponding challenge. :param .challenges.DVSNI chall: Corresponding challenge.
:param str domain: Domain name being validated. :param str domain: Domain name being validated.
:param public_key: Public key for the key pair :type account_public_key:
being authorized. If ``None`` key verification is not
performed!
:type public_key:
`~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey` `~cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey`
or or
`~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey` `~cryptography.hazmat.primitives.asymmetric.dsa.DSAPublicKey`
or or
`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey` `~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey`
wrapped in `.ComparableKey wrapped in `.ComparableKey
:param OpenSSL.crypto.X509 cert: :param OpenSSL.crypto.X509 cert: Optional certificate. If not
provided (``None``) certificate will be retrieved using
`probe_cert`.
:returns: ``True`` iff client's control of the domain has been :returns: ``True`` iff client's control of the domain has been
verified, ``False`` otherwise. verified, ``False`` otherwise.
:rtype: bool :rtype: bool
""" """
# TODO: check "It is a valid self-signed certificate" and # pylint: disable=no-member
# return False if not if not self.validation.verify(key=account_public_key):
# pylint: disable=protected-access
sans = crypto_util._pyopenssl_cert_or_req_san(cert)
logging.debug('Certificate %s. SANs: %s', cert.digest('sha1'), sans)
cert = x509.load_der_x509_certificate(
OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_ASN1, cert),
default_backend())
if public_key is None:
logging.warn('No key verification is performed')
elif public_key != jose.ComparableKey(cert.public_key()):
return False return False
return domain in sans and self.z_domain(chall).decode() in sans # TODO: it's not checked that payload has exectly 2 fields!
try:
decoded_chall = self.chall
except jose.DeserializationError as error:
logger.debug(error, exc_info=True)
return False
if decoded_chall.token != chall.token:
logger.debug("Wrong token: expected %r, found %r",
chall.token, decoded_chall.token)
return False
if cert is None:
try:
cert = self.probe_cert(domain=domain, **kwargs)
except errors.Error as error:
logger.debug(error, exc_info=True)
return False
return self.verify_cert(cert)
@Challenge.register @Challenge.register
@@ -347,23 +432,6 @@ class RecoveryContactResponse(ChallengeResponse):
token = jose.Field("token", omitempty=True) token = jose.Field("token", omitempty=True)
@Challenge.register
class RecoveryToken(ContinuityChallenge):
"""ACME "recoveryToken" challenge."""
typ = "recoveryToken"
@ChallengeResponse.register
class RecoveryTokenResponse(ChallengeResponse):
"""ACME "recoveryToken" challenge response.
:ivar unicode token:
"""
typ = "recoveryToken"
token = jose.Field("token", omitempty=True)
@Challenge.register @Challenge.register
class ProofOfPossession(ContinuityChallenge): class ProofOfPossession(ContinuityChallenge):
"""ACME "proofOfPossession" challenge. """ACME "proofOfPossession" challenge.
+165 -170
View File
@@ -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):
@@ -39,56 +40,36 @@ class SimpleHTTPTest(unittest.TestCase):
from acme.challenges import SimpleHTTP from acme.challenges import SimpleHTTP
hash(SimpleHTTP.from_json(self.jmsg)) hash(SimpleHTTP.from_json(self.jmsg))
def test_good_token(self):
self.assertTrue(self.msg.good_token)
self.assertFalse(
self.msg.update(token=b'..').good_token)
class SimpleHTTPResponseTest(unittest.TestCase): class SimpleHTTPResponseTest(unittest.TestCase):
# pylint: disable=too-many-instance-attributes # pylint: disable=too-many-instance-attributes
def setUp(self): def setUp(self):
from acme.challenges import SimpleHTTPResponse from acme.challenges import SimpleHTTPResponse
self.msg_http = SimpleHTTPResponse( self.msg_http = SimpleHTTPResponse(tls=False)
path='6tbIMBC5Anhl5bOlWT5ZFA', tls=False) self.msg_https = SimpleHTTPResponse(tls=True)
self.msg_https = SimpleHTTPResponse(path='6tbIMBC5Anhl5bOlWT5ZFA')
self.jmsg_http = { self.jmsg_http = {
'resource': 'challenge', 'resource': 'challenge',
'type': 'simpleHttp', 'type': 'simpleHttp',
'path': '6tbIMBC5Anhl5bOlWT5ZFA',
'tls': False, 'tls': False,
} }
self.jmsg_https = { self.jmsg_https = {
'resource': 'challenge', 'resource': 'challenge',
'type': 'simpleHttp', 'type': 'simpleHttp',
'path': '6tbIMBC5Anhl5bOlWT5ZFA',
'tls': True, 'tls': True,
} }
from acme.challenges import SimpleHTTP from acme.challenges import SimpleHTTP
self.chall = SimpleHTTP(token="foo") self.chall = SimpleHTTP(token=(b"x" * 16))
self.resp_http = SimpleHTTPResponse(path="bar", tls=False) self.resp_http = SimpleHTTPResponse(tls=False)
self.resp_https = SimpleHTTPResponse(path="bar", tls=True) self.resp_https = SimpleHTTPResponse(tls=True)
self.good_headers = {'Content-Type': SimpleHTTPResponse.CONTENT_TYPE} self.good_headers = {'Content-Type': SimpleHTTPResponse.CONTENT_TYPE}
def test_good_path(self):
self.assertTrue(self.msg_http.good_path)
self.assertTrue(self.msg_https.good_path)
self.assertFalse(
self.msg_http.update(path=(self.msg_http.path * 10)).good_path)
def test_scheme(self):
self.assertEqual('http', self.msg_http.scheme)
self.assertEqual('https', self.msg_https.scheme)
def test_port(self):
self.assertEqual(80, self.msg_http.port)
self.assertEqual(443, self.msg_https.port)
def test_uri(self):
self.assertEqual(
'http://example.com/.well-known/acme-challenge/'
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg_http.uri('example.com'))
self.assertEqual(
'https://example.com/.well-known/acme-challenge/'
'6tbIMBC5Anhl5bOlWT5ZFA', self.msg_https.uri('example.com'))
def test_to_partial_json(self): def test_to_partial_json(self):
self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json()) self.assertEqual(self.jmsg_http, self.msg_http.to_partial_json())
self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json()) self.assertEqual(self.jmsg_https, self.msg_https.to_partial_json())
@@ -105,6 +86,63 @@ class SimpleHTTPResponseTest(unittest.TestCase):
hash(SimpleHTTPResponse.from_json(self.jmsg_http)) hash(SimpleHTTPResponse.from_json(self.jmsg_http))
hash(SimpleHTTPResponse.from_json(self.jmsg_https)) hash(SimpleHTTPResponse.from_json(self.jmsg_https))
def test_scheme(self):
self.assertEqual('http', self.msg_http.scheme)
self.assertEqual('https', self.msg_https.scheme)
def test_port(self):
self.assertEqual(80, self.msg_http.port)
self.assertEqual(443, self.msg_https.port)
def test_uri(self):
self.assertEqual(
'http://example.com/.well-known/acme-challenge/'
'eHh4eHh4eHh4eHh4eHh4eA', self.msg_http.uri(
'example.com', self.chall))
self.assertEqual(
'https://example.com/.well-known/acme-challenge/'
'eHh4eHh4eHh4eHh4eHh4eA', self.msg_https.uri(
'example.com', self.chall))
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_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_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(token=b'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):
for resp in self.resp_http, self.resp_https: for resp in self.resp_http, self.resp_https:
@@ -132,7 +170,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)
@@ -142,19 +181,12 @@ class DVSNITest(unittest.TestCase):
def setUp(self): def setUp(self):
from acme.challenges import DVSNI from acme.challenges import DVSNI
self.msg = DVSNI( self.msg = DVSNI(
r=b"O*\xb4-\xad\xec\x95>\xed\xa9\r0\x94\xe8\x97\x9c&6" token=jose.b64decode('a82d5ff8ef740d12881f6d3c2277ab2e'))
b"\xbf'\xb3\xed\x9a9nX\x0f'\\m\xe7\x12",
nonce=b'\xa8-_\xf8\xeft\r\x12\x88\x1fm<"w\xab.')
self.jmsg = { self.jmsg = {
'type': 'dvsni', 'type': 'dvsni',
'r': 'Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI', 'token': 'a82d5ff8ef740d12881f6d3c2277ab2e',
'nonce': 'a82d5ff8ef740d12881f6d3c2277ab2e',
} }
def test_nonce_domain(self):
self.assertEqual(b'a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid',
self.msg.nonce_domain)
def test_to_partial_json(self): def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json()) self.assertEqual(self.jmsg, self.msg.to_partial_json())
@@ -166,17 +198,66 @@ class DVSNITest(unittest.TestCase):
from acme.challenges import DVSNI from acme.challenges import DVSNI
hash(DVSNI.from_json(self.jmsg)) hash(DVSNI.from_json(self.jmsg))
def test_from_json_invalid_r_length(self): def test_from_json_invalid_token_length(self):
from acme.challenges import DVSNI from acme.challenges import DVSNI
self.jmsg['r'] = 'abcd' self.jmsg['token'] = jose.encode_b64jose(b'abcd')
self.assertRaises( self.assertRaises(
jose.DeserializationError, DVSNI.from_json, self.jmsg) jose.DeserializationError, DVSNI.from_json, self.jmsg)
def test_from_json_invalid_nonce_length(self): def test_gen_response(self):
key = jose.JWKRSA(key=KEY)
from acme.challenges import DVSNI from acme.challenges import DVSNI
self.jmsg['nonce'] = 'abcd' self.assertEqual(self.msg, DVSNI.json_loads(
self.assertRaises( self.msg.gen_response(key).validation.payload.decode()))
jose.DeserializationError, DVSNI.from_json, self.jmsg)
class DVSNIResponseTest(unittest.TestCase):
# pylint: disable=too-many-instance-attributes
def setUp(self):
self.key = jose.JWKRSA(key=KEY)
from acme.challenges import DVSNI
self.chall = DVSNI(
token=jose.b64decode(b'a82d5ff8ef740d12881f6d3c2277ab2e'))
from acme.challenges import DVSNIResponse
self.validation = jose.JWS.sign(
payload=self.chall.json_dumps(sort_keys=True).encode(),
key=self.key, alg=jose.RS256)
self.msg = DVSNIResponse(validation=self.validation)
self.jmsg_to = {
'resource': 'challenge',
'type': 'dvsni',
'validation': self.validation,
}
self.jmsg_from = {
'resource': 'challenge',
'type': 'dvsni',
'validation': self.validation.to_json(),
}
# pylint: disable=invalid-name
label1 = b'e2df3498860637c667fedadc5a8494ec'
label2 = b'09dcc75553c9b3bd73662b50e71b1e42'
self.z = label1 + label2
self.z_domain = label1 + b'.' + label2 + b'.acme.invalid'
self.domain = 'foo.com'
def test_z_and_domain(self):
self.assertEqual(self.z, self.msg.z)
self.assertEqual(self.z_domain, self.msg.z_domain)
def test_to_partial_json(self):
self.assertEqual(self.jmsg_to, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import DVSNIResponse
self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg_from))
def test_from_json_hashable(self):
from acme.challenges import DVSNIResponse
hash(DVSNIResponse.from_json(self.jmsg_from))
@mock.patch('acme.challenges.socket.gethostbyname') @mock.patch('acme.challenges.socket.gethostbyname')
@mock.patch('acme.challenges.crypto_util._probe_sni') @mock.patch('acme.challenges.crypto_util._probe_sni')
@@ -186,7 +267,7 @@ class DVSNITest(unittest.TestCase):
mock_gethostbyname.assert_called_once_with('foo.com') mock_gethostbyname.assert_called_once_with('foo.com')
mock_probe_sni.assert_called_once_with( mock_probe_sni.assert_called_once_with(
host='127.0.0.1', port=self.msg.PORT, host='127.0.0.1', port=self.msg.PORT,
name=b'a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid') name=self.z_domain)
self.msg.probe_cert('foo.com', host='8.8.8.8') self.msg.probe_cert('foo.com', host='8.8.8.8')
mock_probe_sni.assert_called_with( mock_probe_sni.assert_called_with(
@@ -203,88 +284,54 @@ class DVSNITest(unittest.TestCase):
self.msg.probe_cert('foo.com', name=b'xxx') self.msg.probe_cert('foo.com', name=b'xxx')
mock_probe_sni.assert_called_with( mock_probe_sni.assert_called_with(
host=mock.ANY, port=mock.ANY, host=mock.ANY, port=mock.ANY,
name=b'a82d5ff8ef740d12881f6d3c2277ab2e.acme.invalid') name=self.z_domain)
def test_gen_verify_cert(self):
key1 = test_util.load_pyopenssl_private_key('rsa512_key.pem')
cert, key2 = self.msg.gen_cert(key1)
self.assertEqual(key1, key2)
self.assertTrue(self.msg.verify_cert(cert))
class DVSNIResponseTest(unittest.TestCase): def test_gen_verify_cert_gen_key(self):
cert, key = self.msg.gen_cert()
self.assertTrue(isinstance(key, OpenSSL.crypto.PKey))
self.assertTrue(self.msg.verify_cert(cert))
def setUp(self): def test_verify_bad_cert(self):
from acme.challenges import DVSNIResponse self.assertFalse(self.msg.verify_cert(test_util.load_cert('cert.pem')))
# pylint: disable=invalid-name
s = '9dbjsl3gTAtOnEtKFEmhS6Mj-ajNjDcOmRkp3Lfzm3c'
self.msg = DVSNIResponse(s=jose.decode_b64jose(s))
self.jmsg = {
'resource': 'challenge',
'type': 'dvsni',
's': s,
}
from acme.challenges import DVSNI def test_simple_verify_wrong_account_key(self):
self.chall = DVSNI( self.assertFalse(self.msg.simple_verify(
r=jose.decode_b64jose('Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI'), self.chall, self.domain, jose.JWKRSA.load(
nonce=jose.decode_b64jose('a82d5ff8ef740d12881f6d3c2277ab2e')) test_util.load_vector('rsa256_key.pem')).public_key()))
self.z = (b'38e612b0397cc2624a07d351d7ef50e4'
b'6134c0213d9ed52f7d7c611acaeed41b')
self.domain = 'foo.com'
self.key = test_util.load_pyopenssl_private_key('rsa512_key.pem')
self.public_key = test_util.load_rsa_private_key(
'rsa512_key.pem').public_key()
def test_z_and_domain(self): def test_simple_verify_wrong_payload(self):
# pylint: disable=invalid-name for payload in b'', b'{}':
self.assertEqual(self.z, self.msg.z(self.chall)) msg = self.msg.update(validation=jose.JWS.sign(
self.assertEqual( payload=payload, key=self.key, alg=jose.RS256))
self.z + b'.acme.invalid', self.msg.z_domain(self.chall)) self.assertFalse(msg.simple_verify(
self.chall, self.domain, self.key.public_key()))
def test_to_partial_json(self): def test_simple_verify_wrong_token(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json()) msg = self.msg.update(validation=jose.JWS.sign(
payload=self.chall.update(token=b'b'*20).json_dumps().encode(),
key=self.key, alg=jose.RS256))
self.assertFalse(msg.simple_verify(
self.chall, self.domain, self.key.public_key()))
def test_from_json(self): @mock.patch('acme.challenges.DVSNIResponse.verify_cert', autospec=True)
from acme.challenges import DVSNIResponse
self.assertEqual(self.msg, DVSNIResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import DVSNIResponse
hash(DVSNIResponse.from_json(self.jmsg))
@mock.patch('acme.challenges.DVSNIResponse.verify_cert')
def test_simple_verify(self, mock_verify_cert): def test_simple_verify(self, mock_verify_cert):
chall = mock.Mock() mock_verify_cert.return_value = mock.sentinel.verification
chall.probe_cert.return_value = mock.sentinel.cert self.assertEqual(mock.sentinel.verification, self.msg.simple_verify(
mock_verify_cert.return_value = 'x' self.chall, self.domain, self.key.public_key(),
self.assertEqual('x', self.msg.simple_verify( cert=mock.sentinel.cert))
chall, mock.sentinel.domain, mock.sentinel.key)) mock_verify_cert.assert_called_once_with(self.msg, mock.sentinel.cert)
chall.probe_cert.assert_called_once_with(domain=mock.sentinel.domain)
self.msg.verify_cert.assert_called_once_with(
chall, mock.sentinel.domain, mock.sentinel.key,
mock.sentinel.cert)
def test_simple_verify_false_on_probe_error(self): def test_simple_verify_false_on_probe_error(self):
chall = mock.Mock() chall = mock.Mock()
chall.probe_cert.side_effect = errors.Error chall.probe_cert.side_effect = errors.Error
self.assertFalse(self.msg.simple_verify( self.assertFalse(self.msg.simple_verify(
chall=chall, domain=None, public_key=None)) self.chall, self.domain, self.key.public_key()))
def test_gen_verify_cert_postive_no_key(self):
cert = self.msg.gen_cert(self.chall, self.domain, self.key)
self.assertTrue(self.msg.verify_cert(
self.chall, self.domain, public_key=None, cert=cert))
def test_gen_verify_cert_postive_with_key(self):
cert = self.msg.gen_cert(self.chall, self.domain, self.key)
self.assertTrue(self.msg.verify_cert(
self.chall, self.domain, public_key=self.public_key, cert=cert))
def test_gen_verify_cert_negative_with_wrong_key(self):
cert = self.msg.gen_cert(self.chall, self.domain, self.key)
key = test_util.load_rsa_private_key('rsa256_key.pem').public_key()
self.assertFalse(self.msg.verify_cert(
self.chall, self.domain, public_key=key, cert=cert))
def test_gen_verify_cert_negative(self):
cert = self.msg.gen_cert(self.chall, self.domain + 'x', self.key)
self.assertFalse(self.msg.verify_cert(
self.chall, self.domain, public_key=None, cert=cert))
class RecoveryContactTest(unittest.TestCase): class RecoveryContactTest(unittest.TestCase):
@@ -360,58 +407,6 @@ class RecoveryContactResponseTest(unittest.TestCase):
self.assertEqual(self.jmsg, msg.to_partial_json()) self.assertEqual(self.jmsg, msg.to_partial_json())
class RecoveryTokenTest(unittest.TestCase):
def setUp(self):
from acme.challenges import RecoveryToken
self.msg = RecoveryToken()
self.jmsg = {'type': 'recoveryToken'}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import RecoveryToken
self.assertEqual(self.msg, RecoveryToken.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import RecoveryToken
hash(RecoveryToken.from_json(self.jmsg))
class RecoveryTokenResponseTest(unittest.TestCase):
def setUp(self):
from acme.challenges import RecoveryTokenResponse
self.msg = RecoveryTokenResponse(token='23029d88d9e123e')
self.jmsg = {
'resource': 'challenge',
'type': 'recoveryToken',
'token': '23029d88d9e123e'
}
def test_to_partial_json(self):
self.assertEqual(self.jmsg, self.msg.to_partial_json())
def test_from_json(self):
from acme.challenges import RecoveryTokenResponse
self.assertEqual(
self.msg, RecoveryTokenResponse.from_json(self.jmsg))
def test_from_json_hashable(self):
from acme.challenges import RecoveryTokenResponse
hash(RecoveryTokenResponse.from_json(self.jmsg))
def test_json_without_optionals(self):
del self.jmsg['token']
from acme.challenges import RecoveryTokenResponse
msg = RecoveryTokenResponse.from_json(self.jmsg)
self.assertTrue(msg.token is None)
self.assertEqual(self.jmsg, msg.to_partial_json())
class ProofOfPossessionHintsTest(unittest.TestCase): class ProofOfPossessionHintsTest(unittest.TestCase):
def setUp(self): def setUp(self):
+1 -1
View File
@@ -44,7 +44,7 @@ class ClientTest(unittest.TestCase):
# Registration # Registration
self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212') self.contact = ('mailto:cert-admin@example.com', 'tel:+12025551212')
reg = messages.Registration( reg = messages.Registration(
contact=self.contact, key=KEY.public_key(), recovery_token='t') contact=self.contact, key=KEY.public_key())
self.new_reg = messages.NewRegistration(**dict(reg)) self.new_reg = messages.NewRegistration(**dict(reg))
self.regr = messages.RegistrationResource( self.regr = messages.RegistrationResource(
body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1', body=reg, uri='https://www.letsencrypt-demo.org/acme/reg/1',
+8 -3
View File
@@ -155,13 +155,18 @@ def _pyopenssl_cert_or_req_san(cert_or_req):
for part in parts if part.startswith(prefix)] for part in parts if part.startswith(prefix)]
def gen_ss_cert(key, domains, not_before=None, validity=(7 * 24 * 60 * 60)): def gen_ss_cert(key, domains, not_before=None,
validity=(7 * 24 * 60 * 60), force_san=True):
"""Generate new self-signed certificate. """Generate new self-signed certificate.
:type domains: `list` of `unicode` :type domains: `list` of `unicode`
:param OpenSSL.crypto.PKey key: :param OpenSSL.crypto.PKey key:
:param bool force_san:
Uses key and contains all domains. If more than one domain is provided, all of the domains are put into
``subjectAltName`` X.509 extension and first domain is set as the
subject CN. If only one domain is provided no ``subjectAltName``
extension is used, unless `force_san` is ``True``.
""" """
assert domains, "Must provide one or more hostnames for the cert." assert domains, "Must provide one or more hostnames for the cert."
@@ -178,7 +183,7 @@ def gen_ss_cert(key, domains, not_before=None, validity=(7 * 24 * 60 * 60)):
# TODO: what to put into cert.get_subject()? # TODO: what to put into cert.get_subject()?
cert.set_issuer(cert.get_subject()) cert.set_issuer(cert.get_subject())
if len(domains) > 1: if force_san or len(domains) > 1:
extensions.append(OpenSSL.crypto.X509Extension( extensions.append(OpenSSL.crypto.X509Extension(
b"subjectAltName", b"subjectAltName",
critical=False, critical=False,
+25 -2
View File
@@ -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.
@@ -31,8 +56,6 @@ class Resource(jose.Field):
def __init__(self, resource_type, *args, **kwargs): def __init__(self, resource_type, *args, **kwargs):
self.resource_type = resource_type self.resource_type = resource_type
super(Resource, self).__init__( super(Resource, self).__init__(
# TODO: omitempty used only to trick
# JSONObjectWithFieldsMeta._defaults..., server implementation
'resource', default=resource_type, *args, **kwargs) 'resource', default=resource_type, *args, **kwargs)
def decode(self, value): def decode(self, value):
+20
View File
@@ -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."""
+4
View File
@@ -8,6 +8,10 @@ class Error(Exception):
class DeserializationError(Error): class DeserializationError(Error):
"""JSON deserialization error.""" """JSON deserialization error."""
def __str__(self):
return "Deserialization error: {0}".format(
super(DeserializationError, self).__str__())
class SerializationError(Error): class SerializationError(Error):
"""JSON serialization error.""" """JSON serialization error."""
+6 -1
View File
@@ -5,6 +5,7 @@ import json
import six import six
from acme.jose import errors
from acme.jose import util from acme.jose import util
# pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class # pylint: disable=no-self-argument,no-method-argument,no-init,inherit-non-class
@@ -172,7 +173,11 @@ class JSONDeSerializable(object):
@classmethod @classmethod
def json_loads(cls, json_string): def json_loads(cls, json_string):
"""Deserialize from JSON document string.""" """Deserialize from JSON document string."""
return cls.from_json(json.loads(json_string)) try:
loads = json.loads(json_string)
except ValueError as error:
raise errors.DeserializationError(error)
return cls.from_json(loads)
def json_dumps(self, **kwargs): def json_dumps(self, **kwargs):
"""Dump to JSON string using proper serializer. """Dump to JSON string using proper serializer.
+21 -2
View File
@@ -221,6 +221,22 @@ class JSONObjectWithFields(util.ImmutableMap, interfaces.JSONDeSerializable):
super(JSONObjectWithFields, self).__init__( super(JSONObjectWithFields, self).__init__(
**(dict(self._defaults(), **kwargs))) **(dict(self._defaults(), **kwargs)))
def encode(self, name):
"""Encode a single field.
:param str name: Name of the field to be encoded.
:raises erors.SerializationError: if field cannot be serialized
:raises errors.Error: if field could not be found
"""
try:
field = self._fields[name]
except KeyError:
raise errors.Error("Field not found: {0}".format(name))
return field.encode(getattr(self, name))
def fields_to_partial_json(self): def fields_to_partial_json(self):
"""Serialize fields to JSON.""" """Serialize fields to JSON."""
jobj = {} jobj = {}
@@ -310,7 +326,8 @@ def decode_b64jose(data, size=None, minimum=False):
if size is not None and ((not minimum and len(decoded) != size) if size is not None and ((not minimum and len(decoded) != size)
or (minimum and len(decoded) < size)): or (minimum and len(decoded) < size)):
raise errors.DeserializationError() raise errors.DeserializationError(
"Expected at least or exactly {0} bytes".format(size))
return decoded return decoded
@@ -418,7 +435,9 @@ class TypedJSONObjectWithFields(JSONObjectWithFields):
def get_type_cls(cls, jobj): def get_type_cls(cls, jobj):
"""Get the registered class for ``jobj``.""" """Get the registered class for ``jobj``."""
if cls in six.itervalues(cls.TYPES): if cls in six.itervalues(cls.TYPES):
assert jobj[cls.type_field_name] if cls.type_field_name not in jobj:
raise errors.DeserializationError(
"Missing type field ({0})".format(cls.type_field_name))
# cls is already registered type_cls, force to use it # cls is already registered type_cls, force to use it
# so that, e.g Revocation.from_json(jobj) fails if # so that, e.g Revocation.from_json(jobj) fails if
# jobj["type"] != "revocation". # jobj["type"] != "revocation".
+12
View File
@@ -160,6 +160,18 @@ class JSONObjectWithFieldsTest(unittest.TestCase):
def test_init_defaults(self): def test_init_defaults(self):
self.assertEqual(self.mock, self.MockJSONObjectWithFields(y=2, z=3)) self.assertEqual(self.mock, self.MockJSONObjectWithFields(y=2, z=3))
def test_encode(self):
self.assertEqual(10, self.MockJSONObjectWithFields(
x=5, y=0, z=0).encode("x"))
def test_encode_wrong_field(self):
self.assertRaises(errors.Error, self.mock.encode, 'foo')
def test_encode_serialization_error_passthrough(self):
self.assertRaises(
errors.SerializationError,
self.MockJSONObjectWithFields(y=500, z=None).encode, "y")
def test_fields_to_partial_json_omits_empty(self): def test_fields_to_partial_json_omits_empty(self):
self.assertEqual(self.mock.fields_to_partial_json(), {'y': 2, 'Z': 3}) self.assertEqual(self.mock.fields_to_partial_json(), {'y': 2, 'Z': 3})
-2
View File
@@ -156,7 +156,6 @@ class Registration(ResourceBody):
:ivar acme.jose.jwk.JWK key: Public key. :ivar acme.jose.jwk.JWK key: Public key.
:ivar tuple contact: Contact information following ACME spec, :ivar tuple contact: Contact information following ACME spec,
`tuple` of `unicode`. `tuple` of `unicode`.
:ivar unicode recovery_token:
:ivar unicode agreement: :ivar unicode agreement:
""" """
@@ -164,7 +163,6 @@ class Registration(ResourceBody):
# JWS.signature.combined.jwk # JWS.signature.combined.jwk
key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json) key = jose.Field('key', omitempty=True, decoder=jose.JWK.from_json)
contact = jose.Field('contact', omitempty=True, default=()) contact = jose.Field('contact', omitempty=True, default=())
recovery_token = jose.Field('recoveryToken', omitempty=True)
agreement = jose.Field('agreement', omitempty=True) agreement = jose.Field('agreement', omitempty=True)
phone_prefix = 'tel:' phone_prefix = 'tel:'
+5 -8
View File
@@ -101,18 +101,14 @@ class RegistrationTest(unittest.TestCase):
'mailto:admin@foo.com', 'mailto:admin@foo.com',
'tel:1234', 'tel:1234',
) )
recovery_token = 'XYZ'
agreement = 'https://letsencrypt.org/terms' agreement = 'https://letsencrypt.org/terms'
from acme.messages import Registration from acme.messages import Registration
self.reg = Registration( self.reg = Registration(key=key, contact=contact, agreement=agreement)
key=key, contact=contact, recovery_token=recovery_token,
agreement=agreement)
self.reg_none = Registration() self.reg_none = Registration()
self.jobj_to = { self.jobj_to = {
'contact': contact, 'contact': contact,
'recoveryToken': recovery_token,
'agreement': agreement, 'agreement': agreement,
'key': key, 'key': key,
} }
@@ -228,11 +224,12 @@ class AuthorizationTest(unittest.TestCase):
self.challbs = ( self.challbs = (
ChallengeBody( ChallengeBody(
uri='http://challb1', status=STATUS_VALID, uri='http://challb1', status=STATUS_VALID,
chall=challenges.SimpleHTTP(token='IlirfxKKXAsHtmzK29Pj8A')), chall=challenges.SimpleHTTP(token=b'IlirfxKKXAsHtmzK29Pj8A')),
ChallengeBody(uri='http://challb2', status=STATUS_VALID, ChallengeBody(uri='http://challb2', status=STATUS_VALID,
chall=challenges.DNS(token='DGyRejmCefe7v4NfDGDKfA')), chall=challenges.DNS(
token=b'DGyRejmCefe7v4NfDGDKfA')),
ChallengeBody(uri='http://challb3', status=STATUS_VALID, ChallengeBody(uri='http://challb3', status=STATUS_VALID,
chall=challenges.RecoveryToken()), chall=challenges.RecoveryContact()),
) )
combinations = ((0, 2), (1, 2)) combinations = ((0, 2), (1, 2))
+4 -8
View File
@@ -94,15 +94,11 @@ def report_new_account(acc, config):
config.config_dir), config.config_dir),
reporter.MEDIUM_PRIORITY, True) reporter.MEDIUM_PRIORITY, True)
assert acc.regr.body.recovery_token is not None
recovery_msg = ("If you lose your account credentials, you can recover "
"them using the token \"{0}\". You must write that down "
"and put it in a safe place.".format(
acc.regr.body.recovery_token))
if acc.regr.body.emails: if acc.regr.body.emails:
recovery_msg += (" Another recovery method will be e-mails sent to " recovery_msg = ("If you lose your account credentials, you can "
"{0}.".format(", ".join(acc.regr.body.emails))) "recover through e-mails sent to {0}.".format(
reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY, True) ", ".join(acc.regr.body.emails)))
reporter.add_message(recovery_msg, reporter.HIGH_PRIORITY, True)
class AccountMemoryStorage(interfaces.AccountStorage): class AccountMemoryStorage(interfaces.AccountStorage):
+41 -20
View File
@@ -17,18 +17,22 @@ Note, that all annotated challenges act as a proxy objects::
achall.token == challb.token achall.token == challb.token
""" """
import logging
import os
import OpenSSL import OpenSSL
from acme import challenges from acme import challenges
from acme.jose import util as jose_util from acme import jose
from letsencrypt import crypto_util
logger = logging.getLogger(__name__)
# pylint: disable=too-few-public-methods # pylint: disable=too-few-public-methods
class AnnotatedChallenge(jose_util.ImmutableMap): class AnnotatedChallenge(jose.ImmutableMap):
"""Client annotated challenge. """Client annotated challenge.
Wraps around server provided challenge and annotates with data Wraps around server provided challenge and annotates with data
@@ -45,33 +49,56 @@ class AnnotatedChallenge(jose_util.ImmutableMap):
class DVSNI(AnnotatedChallenge): class DVSNI(AnnotatedChallenge):
"""Client annotated "dvsni" ACME challenge.""" """Client annotated "dvsni" ACME challenge.
__slots__ = ('challb', 'domain', 'key')
:ivar .Account account:
"""
__slots__ = ('challb', 'domain', 'account')
acme_type = challenges.DVSNI acme_type = challenges.DVSNI
def gen_cert_and_response(self, s=None): # pylint: disable=invalid-name def gen_cert_and_response(self, key_pem=None, bits=2048, alg=jose.RS256):
"""Generate a DVSNI cert and response. """Generate a DVSNI cert and response.
:returns: ``(cert_pem, response)`` tuple, where ``cert_pem`` is the PEM :param bytes key_pem: Private PEM-encoded key used for
encoded certificate and ``response`` is an instance certificate generation. If none provided, a fresh key will
:class:`acme.challenges.DVSNIResponse`. be generated.
:param int bits: Number of bits for fresh key generation.
:param .JWAAlgorithm alg:
:returns: ``(response, cert_pem, key_pem)`` tuple, where
``response`` is an instance of
`acme.challenges.DVSNIResponse`, ``cert_pem`` is the
PEM-encoded certificate and ``key_pem`` is PEM-encoded
private key.
:rtype: tuple :rtype: tuple
""" """
key = crypto_util.private_jwk_to_pyopenssl(self.key) key = None if key_pem is None else OpenSSL.crypto.load_privatekey(
response = challenges.DVSNIResponse(s=s) OpenSSL.crypto.FILETYPE_PEM, key_pem)
cert = response.gen_cert(self.challb.chall, self.domain, key) response = self.challb.chall.gen_response(self.account.key, alg=alg)
cert, key = response.gen_cert(key=key, bits=bits)
cert_pem = OpenSSL.crypto.dump_certificate( cert_pem = OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, cert) OpenSSL.crypto.FILETYPE_PEM, cert)
key_pem = OpenSSL.crypto.dump_privatekey(
OpenSSL.crypto.FILETYPE_PEM, key)
return cert_pem, response return response, cert_pem, key_pem
class SimpleHTTP(AnnotatedChallenge): class SimpleHTTP(AnnotatedChallenge):
"""Client annotated "simpleHttp" ACME challenge.""" """Client annotated "simpleHttp" ACME challenge."""
__slots__ = ('challb', 'domain', 'key') __slots__ = ('challb', 'domain', 'account')
acme_type = challenges.SimpleHTTP acme_type = challenges.SimpleHTTP
def gen_response_and_validation(self, tls):
response = challenges.SimpleHTTPResponse(tls=tls)
validation = response.gen_validation(self.chall, self.account.key)
logger.debug("Simple HTTP validation payload: %s", validation.payload)
return response, validation
class DNS(AnnotatedChallenge): class DNS(AnnotatedChallenge):
"""Client annotated "dns" ACME challenge.""" """Client annotated "dns" ACME challenge."""
@@ -85,12 +112,6 @@ class RecoveryContact(AnnotatedChallenge):
acme_type = challenges.RecoveryContact acme_type = challenges.RecoveryContact
class RecoveryToken(AnnotatedChallenge):
"""Client annotated "recoveryToken" ACME challenge."""
__slots__ = ('challb', 'domain')
acme_type = challenges.RecoveryToken
class ProofOfPossession(AnnotatedChallenge): class ProofOfPossession(AnnotatedChallenge):
"""Client annotated "proofOfPossession" ACME challenge.""" """Client annotated "proofOfPossession" ACME challenge."""
__slots__ = ('challb', 'domain') __slots__ = ('challb', 'domain')
+6 -12
View File
@@ -322,7 +322,7 @@ class AuthHandler(object):
challb = self.authzr[domain].body.challenges[index] challb = self.authzr[domain].body.challenges[index]
chall = challb.chall chall = challb.chall
achall = challb_to_achall(challb, self.account.key, domain) achall = challb_to_achall(challb, self.account, domain)
if isinstance(chall, challenges.ContinuityChallenge): if isinstance(chall, challenges.ContinuityChallenge):
cont_chall.append(achall) cont_chall.append(achall)
@@ -332,15 +332,11 @@ class AuthHandler(object):
return cont_chall, dv_chall return cont_chall, dv_chall
def challb_to_achall(challb, key, domain): def challb_to_achall(challb, account, domain):
"""Converts a ChallengeBody object to an AnnotatedChallenge. """Converts a ChallengeBody object to an AnnotatedChallenge.
:param challb: ChallengeBody :param .ChallengeBody challb: ChallengeBody
:type challb: :class:`acme.messages.ChallengeBody` :param .Account account:
:param key: Key
:type key: :class:`letsencrypt.le_util.Key`
:param str domain: Domain of the challb :param str domain: Domain of the challb
:returns: Appropriate AnnotatedChallenge :returns: Appropriate AnnotatedChallenge
@@ -352,14 +348,12 @@ def challb_to_achall(challb, key, domain):
if isinstance(chall, challenges.DVSNI): if isinstance(chall, challenges.DVSNI):
return achallenges.DVSNI( return achallenges.DVSNI(
challb=challb, domain=domain, key=key) challb=challb, domain=domain, account=account)
elif isinstance(chall, challenges.SimpleHTTP): elif isinstance(chall, challenges.SimpleHTTP):
return achallenges.SimpleHTTP( return achallenges.SimpleHTTP(
challb=challb, domain=domain, key=key) challb=challb, domain=domain, account=account)
elif isinstance(chall, challenges.DNS): elif isinstance(chall, challenges.DNS):
return achallenges.DNS(challb=challb, domain=domain) return achallenges.DNS(challb=challb, domain=domain)
elif isinstance(chall, challenges.RecoveryToken):
return achallenges.RecoveryToken(challb=challb, domain=domain)
elif isinstance(chall, challenges.RecoveryContact): elif isinstance(chall, challenges.RecoveryContact):
return achallenges.RecoveryContact( return achallenges.RecoveryContact(
challb=challb, domain=domain) challb=challb, domain=domain)
-6
View File
@@ -22,7 +22,6 @@ class NamespaceConfig(object):
- `cert_key_backup` - `cert_key_backup`
- `in_progress_dir` - `in_progress_dir`
- `key_dir` - `key_dir`
- `rec_token_dir`
- `renewer_config_file` - `renewer_config_file`
- `temp_checkpoint_dir` - `temp_checkpoint_dir`
@@ -71,11 +70,6 @@ class NamespaceConfig(object):
def key_dir(self): # pylint: disable=missing-docstring def key_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.config_dir, constants.KEY_DIR) return os.path.join(self.namespace.config_dir, constants.KEY_DIR)
# TODO: This should probably include the server name
@property
def rec_token_dir(self): # pylint: disable=missing-docstring
return os.path.join(self.namespace.work_dir, constants.REC_TOKEN_DIR)
@property @property
def temp_checkpoint_dir(self): # pylint: disable=missing-docstring def temp_checkpoint_dir(self): # pylint: disable=missing-docstring
return os.path.join( return os.path.join(
-4
View File
@@ -88,10 +88,6 @@ LIVE_DIR = "live"
TEMP_CHECKPOINT_DIR = "temp_checkpoint" TEMP_CHECKPOINT_DIR = "temp_checkpoint"
"""Temporary checkpoint directory (relative to `IConfig.work_dir`).""" """Temporary checkpoint directory (relative to `IConfig.work_dir`)."""
REC_TOKEN_DIR = "recovery_tokens"
"""Directory where all recovery tokens are saved (relative to
`IConfig.work_dir`)."""
RENEWAL_CONFIGS_DIR = "configs" RENEWAL_CONFIGS_DIR = "configs"
"""Renewal configs directory, relative to `IConfig.config_dir`.""" """Renewal configs directory, relative to `IConfig.config_dir`."""
+4 -14
View File
@@ -7,16 +7,12 @@ from letsencrypt import achallenges
from letsencrypt import errors from letsencrypt import errors
from letsencrypt import interfaces from letsencrypt import interfaces
from letsencrypt import proof_of_possession from letsencrypt import proof_of_possession
from letsencrypt import recovery_token
class ContinuityAuthenticator(object): class ContinuityAuthenticator(object):
"""IAuthenticator for """IAuthenticator for
:const:`~acme.challenges.ContinuityChallenge` class challenges. :const:`~acme.challenges.ContinuityChallenge` class challenges.
:ivar rec_token: Performs "recoveryToken" challenges.
:type rec_token: :class:`letsencrypt.recovery_token.RecoveryToken`
:ivar proof_of_pos: Performs "proofOfPossession" challenges. :ivar proof_of_pos: Performs "proofOfPossession" challenges.
:type proof_of_pos: :type proof_of_pos:
:class:`letsencrypt.proof_of_possession.Proof_of_Possession` :class:`letsencrypt.proof_of_possession.Proof_of_Possession`
@@ -25,7 +21,7 @@ class ContinuityAuthenticator(object):
zope.interface.implements(interfaces.IAuthenticator) zope.interface.implements(interfaces.IAuthenticator)
# This will have an installer soon for get_key/cert purposes # This will have an installer soon for get_key/cert purposes
def __init__(self, config, installer): def __init__(self, config, installer): # pylint: disable=unused-argument
"""Initialize Client Authenticator. """Initialize Client Authenticator.
:param config: Configuration. :param config: Configuration.
@@ -35,13 +31,11 @@ class ContinuityAuthenticator(object):
:type installer: :class:`letsencrypt.interfaces.IInstaller` :type installer: :class:`letsencrypt.interfaces.IInstaller`
""" """
self.rec_token = recovery_token.RecoveryToken(
config.server, config.rec_token_dir)
self.proof_of_pos = proof_of_possession.ProofOfPossession(installer) self.proof_of_pos = proof_of_possession.ProofOfPossession(installer)
def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use
"""Return list of challenge preferences.""" """Return list of challenge preferences."""
return [challenges.ProofOfPossession, challenges.RecoveryToken] return [challenges.ProofOfPossession]
def perform(self, achalls): def perform(self, achalls):
"""Perform client specific challenges for IAuthenticator""" """Perform client specific challenges for IAuthenticator"""
@@ -49,16 +43,12 @@ class ContinuityAuthenticator(object):
for achall in achalls: for achall in achalls:
if isinstance(achall, achallenges.ProofOfPossession): if isinstance(achall, achallenges.ProofOfPossession):
responses.append(self.proof_of_pos.perform(achall)) responses.append(self.proof_of_pos.perform(achall))
elif isinstance(achall, achallenges.RecoveryToken):
responses.append(self.rec_token.perform(achall))
else: else:
raise errors.ContAuthError("Unexpected Challenge") raise errors.ContAuthError("Unexpected Challenge")
return responses return responses
def cleanup(self, achalls): def cleanup(self, achalls): # pylint: disable=no-self-use
"""Cleanup call for IAuthenticator.""" """Cleanup call for IAuthenticator."""
for achall in achalls: for achall in achalls:
if isinstance(achall, achallenges.RecoveryToken): if not isinstance(achall, achallenges.ProofOfPossession):
self.rec_token.cleanup(achall)
elif not isinstance(achall, achallenges.ProofOfPossession):
raise errors.ContAuthError("Unexpected Challenge") raise errors.ContAuthError("Unexpected Challenge")
+1 -11
View File
@@ -8,7 +8,6 @@ import datetime
import logging import logging
import os import os
from cryptography.hazmat.primitives import serialization
import OpenSSL import OpenSSL
from acme import crypto_util as acme_crypto_util from acme import crypto_util as acme_crypto_util
@@ -215,15 +214,6 @@ def pyopenssl_load_certificate(data):
return _pyopenssl_load(data, OpenSSL.crypto.load_certificate) return _pyopenssl_load(data, OpenSSL.crypto.load_certificate)
def private_jwk_to_pyopenssl(jwk):
"""Convert private JWK to pyOpenSSL key."""
key_pem = jwk.key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption())
return OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, key_pem)
def _get_sans_from_cert_or_req( def _get_sans_from_cert_or_req(
cert_or_req_str, load_func, typ=OpenSSL.crypto.FILETYPE_PEM): cert_or_req_str, load_func, typ=OpenSSL.crypto.FILETYPE_PEM):
try: try:
@@ -238,7 +228,7 @@ def _get_sans_from_cert_or_req(
def get_sans_from_cert(cert, typ=OpenSSL.crypto.FILETYPE_PEM): def get_sans_from_cert(cert, typ=OpenSSL.crypto.FILETYPE_PEM):
"""Get a list of Subject Alternative Names from a certificate. """Get a list of Subject Alternative Names from a certificate.
:param str csr: Certificate (encoded). :param str cert: Certificate (encoded).
:param typ: `OpenSSL.crypto.FILETYPE_PEM` or `OpenSSL.crypto.FILETYPE_ASN1` :param typ: `OpenSSL.crypto.FILETYPE_PEM` or `OpenSSL.crypto.FILETYPE_ASN1`
:returns: A list of Subject Alternative Names. :returns: A list of Subject Alternative Names.
-2
View File
@@ -215,8 +215,6 @@ class IConfig(zope.interface.Interface):
in_progress_dir = zope.interface.Attribute( in_progress_dir = zope.interface.Attribute(
"Directory used before a permanent checkpoint is finalized.") "Directory used before a permanent checkpoint is finalized.")
key_dir = zope.interface.Attribute("Keys storage.") key_dir = zope.interface.Attribute("Keys storage.")
rec_token_dir = zope.interface.Attribute(
"Directory where all recovery tokens are saved.")
temp_checkpoint_dir = zope.interface.Attribute( temp_checkpoint_dir = zope.interface.Attribute(
"Temporary checkpoint directory.") "Temporary checkpoint directory.")
+6 -13
View File
@@ -5,7 +5,6 @@ import re
import shutil import shutil
import tempfile import tempfile
from cryptography.hazmat.primitives import serialization
import zope.interface import zope.interface
from acme.jose import util as jose_util from acme.jose import util as jose_util
@@ -163,13 +162,13 @@ class Dvsni(object):
:rtype: str :rtype: str
""" """
return os.path.join( return os.path.join(self.configurator.config.work_dir,
self.configurator.config.work_dir, achall.nonce_domain + ".crt") achall.chall.encode("token") + ".crt")
def get_key_path(self, achall): def get_key_path(self, achall):
"""Get standardized path to challenge key.""" """Get standardized path to challenge key."""
return os.path.join( return os.path.join(self.configurator.config.work_dir,
self.configurator.config.work_dir, achall.nonce_domain + '.pem') achall.chall.encode("token") + '.pem')
def _setup_challenge_cert(self, achall, s=None): def _setup_challenge_cert(self, achall, s=None):
# pylint: disable=invalid-name # pylint: disable=invalid-name
@@ -180,17 +179,11 @@ class Dvsni(object):
self.configurator.reverter.register_file_creation(True, key_path) self.configurator.reverter.register_file_creation(True, key_path)
self.configurator.reverter.register_file_creation(True, cert_path) self.configurator.reverter.register_file_creation(True, cert_path)
cert_pem, response = achall.gen_cert_and_response(s) response, cert_pem, key_pem = achall.gen_cert_and_response(s)
# Write out challenge cert # Write out challenge cert and key
with open(cert_path, "wb") as cert_chall_fd: with open(cert_path, "wb") as cert_chall_fd:
cert_chall_fd.write(cert_pem) cert_chall_fd.write(cert_pem)
# Write out challenge key
key_pem = achall.key.key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption())
with le_util.safe_open(key_path, 'wb', chmod=0o400) as key_file: with le_util.safe_open(key_path, 'wb', chmod=0o400) as key_file:
key_file.write(key_pem) key_file.write(key_pem)
+9 -20
View File
@@ -120,21 +120,12 @@ class DvsniTest(unittest.TestCase):
achalls = [ achalls = [
achallenges.DVSNI( achallenges.DVSNI(
challb=acme_util.chall_to_challb( challb=acme_util.chall_to_challb(
challenges.DVSNI( challenges.DVSNI(token=b'dvsni1'), "pending"),
r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9" domain="encryption-example.demo", account=mock.Mock(key=auth_key)),
"\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4",
nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18",
), "pending"),
domain="encryption-example.demo", key=auth_key),
achallenges.DVSNI( achallenges.DVSNI(
challb=acme_util.chall_to_challb( challb=acme_util.chall_to_challb(
challenges.DVSNI( challenges.DVSNI(token=b'dvsni2'), "pending"),
r="\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y\x80" domain="letsencrypt.demo", account=mock.Mock(key=auth_key)),
"\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945",
nonce="Y\xed\x01L\xac\x95\xf7pW\xb1\xd7\xa1\xb2\xc5"
"\x96\xba",
), "pending"),
domain="letsencrypt.demo", key=auth_key),
] ]
def setUp(self): def setUp(self):
@@ -153,10 +144,9 @@ class DvsniTest(unittest.TestCase):
# http://www.voidspace.org.uk/python/mock/helpers.html#mock.mock_open # http://www.voidspace.org.uk/python/mock/helpers.html#mock.mock_open
mock_open, mock_safe_open = mock.mock_open(), mock.mock_open() mock_open, mock_safe_open = mock.mock_open(), mock.mock_open()
response = challenges.DVSNIResponse(s="randomS1") response = challenges.DVSNIResponse(validation=mock.Mock())
achall = mock.MagicMock(nonce=self.achalls[0].nonce, achall = mock.MagicMock()
nonce_domain=self.achalls[0].nonce_domain) achall.gen_cert_and_response.return_value = (response, "cert", "key")
achall.gen_cert_and_response.return_value = ("pem", response)
with mock.patch("letsencrypt.plugins.common.open", with mock.patch("letsencrypt.plugins.common.open",
mock_open, create=True): mock_open, create=True):
@@ -168,11 +158,10 @@ class DvsniTest(unittest.TestCase):
# pylint: disable=no-member # pylint: disable=no-member
mock_open.assert_called_once_with(self.sni.get_cert_path(achall), "wb") mock_open.assert_called_once_with(self.sni.get_cert_path(achall), "wb")
mock_open.return_value.write.assert_called_once_with("pem") mock_open.return_value.write.assert_called_once_with("cert")
mock_safe_open.assert_called_once_with( mock_safe_open.assert_called_once_with(
self.sni.get_key_path(achall), "wb", chmod=0o400) self.sni.get_key_path(achall), "wb", chmod=0o400)
mock_safe_open.return_value.write.assert_called_once_with( mock_safe_open.return_value.write.assert_called_once_with("key")
achall.key.key.private_bytes())
if __name__ == "__main__": if __name__ == "__main__":
+8 -6
View File
@@ -1,6 +1,7 @@
"""Manual plugin.""" """Manual plugin."""
import os import os
import logging import logging
import pipes
import shutil import shutil
import signal import signal
import subprocess import subprocess
@@ -55,7 +56,7 @@ command on the target server (as root):
HTTP_TEMPLATE = """\ HTTP_TEMPLATE = """\
mkdir -p {root}/public_html/{response.URI_ROOT_PATH} mkdir -p {root}/public_html/{response.URI_ROOT_PATH}
cd {root}/public_html cd {root}/public_html
echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path} echo -n {validation} > {response.URI_ROOT_PATH}/{encoded_token}
# run only once per server: # run only once per server:
python -c "import BaseHTTPServer, SimpleHTTPServer; \\ python -c "import BaseHTTPServer, SimpleHTTPServer; \\
SimpleHTTPServer.SimpleHTTPRequestHandler.extensions_map = {{'': '{ct}'}}; \\ SimpleHTTPServer.SimpleHTTPRequestHandler.extensions_map = {{'': '{ct}'}}; \\
@@ -67,7 +68,7 @@ s.serve_forever()" """
HTTPS_TEMPLATE = """\ HTTPS_TEMPLATE = """\
mkdir -p {root}/public_html/{response.URI_ROOT_PATH} mkdir -p {root}/public_html/{response.URI_ROOT_PATH}
cd {root}/public_html cd {root}/public_html
echo -n {achall.token} > {response.URI_ROOT_PATH}/{response.path} echo -n {validation} > {response.URI_ROOT_PATH}/{encoded_token}
# run only once per server: # run only once per server:
openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout ../key.pem -out ../cert.pem openssl req -new -newkey rsa:4096 -subj "/" -days 1 -nodes -x509 -keyout ../key.pem -out ../cert.pem
python -c "import BaseHTTPServer, SimpleHTTPServer, ssl; \\ python -c "import BaseHTTPServer, SimpleHTTPServer, ssl; \\
@@ -124,13 +125,13 @@ binary for temporary key/certificate generation.""".replace("\n", "")
# same path for each challenge response would be easier for # same path for each challenge response would be easier for
# users, but will not work if multiple domains point at the # users, but will not work if multiple domains point at the
# same server: default command doesn't support virtual hosts # same server: default command doesn't support virtual hosts
response = challenges.SimpleHTTPResponse( response, validation = achall.gen_response_and_validation(
path=jose.b64encode(os.urandom(18)),
tls=(not self.config.no_simple_http_tls)) tls=(not self.config.no_simple_http_tls))
assert response.good_path # is encoded os.urandom(18) good?
command = self.template.format( command = self.template.format(
root=self._root, achall=achall, response=response, root=self._root, achall=achall, response=response,
validation=pipes.quote(validation.json_dumps()),
encoded_token=achall.chall.encode("token"),
ct=response.CONTENT_TYPE, port=( ct=response.CONTENT_TYPE, port=(
response.port if self.config.simple_http_port is None response.port if self.config.simple_http_port is None
else self.config.simple_http_port)) else self.config.simple_http_port))
@@ -161,7 +162,8 @@ binary for temporary key/certificate generation.""".replace("\n", "")
command=command)) command=command))
if response.simple_verify( if response.simple_verify(
achall.challb, achall.domain, self.config.simple_http_port): achall.chall, achall.domain,
achall.account.key.public_key(), self.config.simple_http_port):
return response return response
else: else:
if self.conf("test-mode") and self._httpd.poll() is not None: if self.conf("test-mode") and self._httpd.poll() is not None:
+24 -25
View File
@@ -6,7 +6,6 @@ import socket
import sys import sys
import time import time
from cryptography.hazmat.primitives import serialization
import OpenSSL import OpenSSL
import zope.component import zope.component
import zope.interface import zope.interface
@@ -14,6 +13,7 @@ import zope.interface
from acme import challenges from acme import challenges
from letsencrypt import achallenges from letsencrypt import achallenges
from letsencrypt import crypto_util
from letsencrypt import interfaces from letsencrypt import interfaces
from letsencrypt.plugins import common from letsencrypt.plugins import common
@@ -28,6 +28,11 @@ class StandaloneAuthenticator(common.Plugin):
the certificate authority. Therefore, it does not rely on any the certificate authority. Therefore, it does not rely on any
existing server program. existing server program.
:param OpenSSL.crypto.PKey private_key: DVSNI challenge certificate
key.
:param sni_names: Mapping from z_domain (`bytes`) to PEM-encoded
certificate (`bytes`).
""" """
zope.interface.implements(interfaces.IAuthenticator) zope.interface.implements(interfaces.IAuthenticator)
zope.interface.classProvides(interfaces.IPluginFactory) zope.interface.classProvides(interfaces.IPluginFactory)
@@ -40,9 +45,12 @@ class StandaloneAuthenticator(common.Plugin):
self.parent_pid = os.getpid() self.parent_pid = os.getpid()
self.subproc_state = None self.subproc_state = None
self.tasks = {} self.tasks = {}
self.sni_names = {}
self.sock = None self.sock = None
self.connection = None self.connection = None
self.private_key = None self.key_pem = crypto_util.make_key(bits=2048)
self.private_key = OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM, self.key_pem)
self.ssl_conn = None self.ssl_conn = None
def prepare(self): def prepare(self):
@@ -121,12 +129,12 @@ class StandaloneAuthenticator(common.Plugin):
""" """
sni_name = connection.get_servername() sni_name = connection.get_servername()
if sni_name in self.tasks: if sni_name in self.sni_names:
pem_cert = self.tasks[sni_name] pem_cert = self.sni_names[sni_name]
else: else:
# TODO: Should we really present a certificate if we get an # TODO: Should we really present a certificate if we get an
# unexpected SNI name? Or should we just disconnect? # unexpected SNI name? Or should we just disconnect?
pem_cert = self.tasks.values()[0] pem_cert = next(self.sni_names.itervalues())
cert = OpenSSL.crypto.load_certificate( cert = OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_PEM, pem_cert) OpenSSL.crypto.FILETYPE_PEM, pem_cert)
new_ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD) new_ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD)
@@ -179,7 +187,7 @@ class StandaloneAuthenticator(common.Plugin):
return False return False
def do_child_process(self, port, key): def do_child_process(self, port):
"""Perform the child process side of the TCP listener task. """Perform the child process side of the TCP listener task.
This should only be called by :meth:`start_listener`. This should only be called by :meth:`start_listener`.
@@ -189,9 +197,6 @@ class StandaloneAuthenticator(common.Plugin):
handler. handler.
:param int port: Which TCP port to bind. :param int port: Which TCP port to bind.
:param key: The private key to use to respond to DVSNI challenge
requests.
:type key: `letsencrypt.le_util.Key`
""" """
signal.signal(signal.SIGINT, self.subproc_signal_handler) signal.signal(signal.SIGINT, self.subproc_signal_handler)
@@ -218,11 +223,6 @@ class StandaloneAuthenticator(common.Plugin):
self.sock.listen(1) self.sock.listen(1)
# Signal that we've successfully bound TCP port # Signal that we've successfully bound TCP port
os.kill(self.parent_pid, signal.SIGIO) os.kill(self.parent_pid, signal.SIGIO)
self.private_key = OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM, key.key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()))
while True: while True:
self.connection, _ = self.sock.accept() self.connection, _ = self.sock.accept()
@@ -245,16 +245,13 @@ class StandaloneAuthenticator(common.Plugin):
self.ssl_conn.shutdown() self.ssl_conn.shutdown()
self.ssl_conn.close() self.ssl_conn.close()
def start_listener(self, port, key): def start_listener(self, port):
"""Start listener. """Start listener.
Create a child process which will start a TCP listener on the Create a child process which will start a TCP listener on the
specified port to perform the specified DVSNI challenges. specified port to perform the specified DVSNI challenges.
:param int port: The TCP port to bind. :param int port: The TCP port to bind.
:param key: The private key to use to respond to DVSNI challenge
requests.
:type key: :class:`letsencrypt.le_util.Key`
:returns: ``True`` or ``False`` to indicate success or failure creating :returns: ``True`` or ``False`` to indicate success or failure creating
the subprocess. the subprocess.
@@ -290,7 +287,7 @@ class StandaloneAuthenticator(common.Plugin):
self.child_pid = os.getpid() self.child_pid = os.getpid()
# do_child_process() is normally not expected to return but # do_child_process() is normally not expected to return but
# should terminate via sys.exit(). # should terminate via sys.exit().
return self.do_child_process(port, key) return self.do_child_process(port)
def already_listening(self, port): # pylint: disable=no-self-use def already_listening(self, port): # pylint: disable=no-self-use
"""Check if a process is already listening on the port. """Check if a process is already listening on the port.
@@ -368,12 +365,14 @@ class StandaloneAuthenticator(common.Plugin):
results_if_failure = [] results_if_failure = []
if not achalls or not isinstance(achalls, list): if not achalls or not isinstance(achalls, list):
raise ValueError(".perform() was called without challenge list") raise ValueError(".perform() was called without challenge list")
# TODO: "bits" should be user-configurable
for achall in achalls: for achall in achalls:
if isinstance(achall, achallenges.DVSNI): if isinstance(achall, achallenges.DVSNI):
# We will attempt to do it # We will attempt to do it
key = achall.key # TODO: bug; one key per start_listener response, cert_pem, _ = achall.gen_cert_and_response(
cert_pem, response = achall.gen_cert_and_response() key_pem=self.key_pem)
self.tasks[achall.nonce_domain] = cert_pem self.sni_names[response.z_domain] = cert_pem
self.tasks[achall.token] = cert_pem
results_if_success.append(response) results_if_success.append(response)
results_if_failure.append(None) results_if_failure.append(None)
else: else:
@@ -392,7 +391,7 @@ class StandaloneAuthenticator(common.Plugin):
return results_if_failure return results_if_failure
# Try to do the authentication; note that this creates # Try to do the authentication; note that this creates
# the listener subprocess via os.fork() # the listener subprocess via os.fork()
if self.start_listener(self.config.dvsni_port, key): if self.start_listener(self.config.dvsni_port):
return results_if_success return results_if_success
else: else:
# TODO: This should probably raise a DVAuthError exception # TODO: This should probably raise a DVAuthError exception
@@ -411,8 +410,8 @@ class StandaloneAuthenticator(common.Plugin):
# Remove this from pending tasks list # Remove this from pending tasks list
for achall in achalls: for achall in achalls:
assert isinstance(achall, achallenges.DVSNI) assert isinstance(achall, achallenges.DVSNI)
if achall.nonce_domain in self.tasks: if achall.token in self.tasks:
del self.tasks[achall.nonce_domain] del self.tasks[achall.token]
else: else:
# Could not find the challenge to remove! # Could not find the challenge to remove!
raise ValueError("could not find the challenge to remove") raise ValueError("could not find the challenge to remove")
@@ -1,13 +1,10 @@
"""Tests for letsencrypt.plugins.standalone.authenticator.""" """Tests for letsencrypt.plugins.standalone.authenticator."""
import os import os
import pkg_resources
import psutil import psutil
import signal import signal
import socket import socket
import unittest import unittest
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import mock import mock
import OpenSSL import OpenSSL
@@ -17,16 +14,14 @@ from acme import jose
from letsencrypt import achallenges from letsencrypt import achallenges
from letsencrypt.tests import acme_util from letsencrypt.tests import acme_util
from letsencrypt.tests import test_util
KEY_PATH = pkg_resources.resource_filename( ACCOUNT = mock.Mock(key=jose.JWKRSA.load(
"letsencrypt.tests", os.path.join("testdata", "rsa512_key.pem")) test_util.load_vector("rsa512_key.pem")))
KEY_DATA = pkg_resources.resource_string( CHALL_KEY_PEM = test_util.load_vector("rsa512_key_2.pem")
"letsencrypt.tests", os.path.join("testdata", "rsa512_key.pem")) CHALL_KEY = OpenSSL.crypto.load_privatekey(
KEY = jose.JWKRSA(key=jose.ComparableRSAKey(serialization.load_pem_private_key( OpenSSL.crypto.FILETYPE_PEM, CHALL_KEY_PEM)
KEY_DATA, password=None, backend=default_backend())))
PRIVATE_KEY = OpenSSL.crypto.load_privatekey(
OpenSSL.crypto.FILETYPE_PEM, KEY_DATA)
CONFIG = mock.Mock(dvsni_port=5001) CONFIG = mock.Mock(dvsni_port=5001)
@@ -80,9 +75,10 @@ class SNICallbackTest(unittest.TestCase):
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None) self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.cert = achallenges.DVSNI( self.cert = achallenges.DVSNI(
challb=acme_util.DVSNI_P, challb=acme_util.DVSNI_P,
domain="example.com", key=KEY).gen_cert_and_response()[0] domain="example.com",
self.authenticator.private_key = PRIVATE_KEY account=ACCOUNT).gen_cert_and_response(key_pem=CHALL_KEY_PEM)[1]
self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} self.authenticator.private_key = CHALL_KEY
self.authenticator.sni_names = {"abcdef.acme.invalid": self.cert}
self.authenticator.child_pid = 12345 self.authenticator.child_pid = 12345
def test_real_servername(self): def test_real_servername(self):
@@ -116,7 +112,7 @@ class ClientSignalHandlerTest(unittest.TestCase):
from letsencrypt.plugins.standalone.authenticator import \ from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None) self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.tasks = {"footoken.acme.invalid": "stuff"}
self.authenticator.child_pid = 12345 self.authenticator.child_pid = 12345
def test_client_signal_handler(self): def test_client_signal_handler(self):
@@ -145,7 +141,7 @@ class SubprocSignalHandlerTest(unittest.TestCase):
from letsencrypt.plugins.standalone.authenticator import \ from letsencrypt.plugins.standalone.authenticator import \
StandaloneAuthenticator StandaloneAuthenticator
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None) self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} self.authenticator.tasks = {"footoken.acme.invalid": "stuff"}
self.authenticator.child_pid = 12345 self.authenticator.child_pid = 12345
self.authenticator.parent_pid = 23456 self.authenticator.parent_pid = 23456
@@ -303,12 +299,12 @@ class PerformTest(unittest.TestCase):
self.achall1 = achallenges.DVSNI( self.achall1 = achallenges.DVSNI(
challb=acme_util.chall_to_challb( challb=acme_util.chall_to_challb(
challenges.DVSNI(r="whee", nonce="foo"), "pending"), challenges.DVSNI(token=b"foo"), "pending"),
domain="foo.example.com", key=KEY) domain="foo.example.com", account=ACCOUNT)
self.achall2 = achallenges.DVSNI( self.achall2 = achallenges.DVSNI(
challb=acme_util.chall_to_challb( challb=acme_util.chall_to_challb(
challenges.DVSNI(r="whee", nonce="bar"), "pending"), challenges.DVSNI(token=b"bar"), "pending"),
domain="bar.example.com", key=KEY) domain="bar.example.com", account=ACCOUNT)
bad_achall = ("This", "Represents", "A Non-DVSNI", "Challenge") bad_achall = ("This", "Represents", "A Non-DVSNI", "Challenge")
self.achalls = [self.achall1, self.achall2, bad_achall] self.achalls = [self.achall1, self.achall2, bad_achall]
@@ -326,16 +322,16 @@ class PerformTest(unittest.TestCase):
result = self.authenticator.perform(self.achalls) result = self.authenticator.perform(self.achalls)
self.assertEqual(len(self.authenticator.tasks), 2) self.assertEqual(len(self.authenticator.tasks), 2)
self.assertTrue( self.assertTrue(
self.authenticator.tasks.has_key(self.achall1.nonce_domain)) self.authenticator.tasks.has_key(self.achall1.token))
self.assertTrue( self.assertTrue(
self.authenticator.tasks.has_key(self.achall2.nonce_domain)) self.authenticator.tasks.has_key(self.achall2.token))
self.assertTrue(isinstance(result, list)) self.assertTrue(isinstance(result, list))
self.assertEqual(len(result), 3) self.assertEqual(len(result), 3)
self.assertTrue(isinstance(result[0], challenges.ChallengeResponse)) self.assertTrue(isinstance(result[0], challenges.ChallengeResponse))
self.assertTrue(isinstance(result[1], challenges.ChallengeResponse)) self.assertTrue(isinstance(result[1], challenges.ChallengeResponse))
self.assertFalse(result[2]) self.assertFalse(result[2])
self.authenticator.start_listener.assert_called_once_with( self.authenticator.start_listener.assert_called_once_with(
CONFIG.dvsni_port, KEY) CONFIG.dvsni_port)
def test_cannot_perform(self): def test_cannot_perform(self):
"""What happens if start_listener() returns False.""" """What happens if start_listener() returns False."""
@@ -345,17 +341,17 @@ class PerformTest(unittest.TestCase):
result = self.authenticator.perform(self.achalls) result = self.authenticator.perform(self.achalls)
self.assertEqual(len(self.authenticator.tasks), 2) self.assertEqual(len(self.authenticator.tasks), 2)
self.assertTrue( self.assertTrue(
self.authenticator.tasks.has_key(self.achall1.nonce_domain)) self.authenticator.tasks.has_key(self.achall1.token))
self.assertTrue( self.assertTrue(
self.authenticator.tasks.has_key(self.achall2.nonce_domain)) self.authenticator.tasks.has_key(self.achall2.token))
self.assertTrue(isinstance(result, list)) self.assertTrue(isinstance(result, list))
self.assertEqual(len(result), 3) self.assertEqual(len(result), 3)
self.assertEqual(result, [None, None, False]) self.assertEqual(result, [None, None, False])
self.authenticator.start_listener.assert_called_once_with( self.authenticator.start_listener.assert_called_once_with(
CONFIG.dvsni_port, KEY) CONFIG.dvsni_port)
def test_perform_with_pending_tasks(self): def test_perform_with_pending_tasks(self):
self.authenticator.tasks = {"foononce.acme.invalid": "cert_data"} self.authenticator.tasks = {"footoken.acme.invalid": "cert_data"}
extra_achall = acme_util.DVSNI_P extra_achall = acme_util.DVSNI_P
self.assertRaises( self.assertRaises(
ValueError, self.authenticator.perform, [extra_achall]) ValueError, self.authenticator.perform, [extra_achall])
@@ -384,7 +380,7 @@ class StartListenerTest(unittest.TestCase):
self.authenticator.do_parent_process = mock.Mock() self.authenticator.do_parent_process = mock.Mock()
self.authenticator.do_parent_process.return_value = True self.authenticator.do_parent_process.return_value = True
mock_fork.return_value = 22222 mock_fork.return_value = 22222
result = self.authenticator.start_listener(1717, "key") result = self.authenticator.start_listener(1717)
# start_listener is expected to return the True or False return # start_listener is expected to return the True or False return
# value from do_parent_process. # value from do_parent_process.
self.assertTrue(result) self.assertTrue(result)
@@ -396,10 +392,9 @@ class StartListenerTest(unittest.TestCase):
self.authenticator.do_parent_process = mock.Mock() self.authenticator.do_parent_process = mock.Mock()
self.authenticator.do_child_process = mock.Mock() self.authenticator.do_child_process = mock.Mock()
mock_fork.return_value = 0 mock_fork.return_value = 0
self.authenticator.start_listener(1717, "key") self.authenticator.start_listener(1717)
self.assertEqual(self.authenticator.child_pid, os.getpid()) self.assertEqual(self.authenticator.child_pid, os.getpid())
self.authenticator.do_child_process.assert_called_once_with( self.authenticator.do_child_process.assert_called_once_with(1717)
1717, "key")
class DoParentProcessTest(unittest.TestCase): class DoParentProcessTest(unittest.TestCase):
@@ -452,9 +447,10 @@ class DoChildProcessTest(unittest.TestCase):
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None) self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.cert = achallenges.DVSNI( self.cert = achallenges.DVSNI(
challb=acme_util.chall_to_challb( challb=acme_util.chall_to_challb(
challenges.DVSNI(r=("x" * 32), nonce="abcdef"), "pending"), challenges.DVSNI(token=b"abcdef"), "pending"),
domain="example.com", key=KEY).gen_cert_and_response()[0] domain="example.com", account=ACCOUNT).gen_cert_and_response(
self.authenticator.private_key = PRIVATE_KEY key_pem=CHALL_KEY_PEM)[1]
self.authenticator.private_key = CHALL_KEY
self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} self.authenticator.tasks = {"abcdef.acme.invalid": self.cert}
self.authenticator.parent_pid = 12345 self.authenticator.parent_pid = 12345
@@ -475,7 +471,7 @@ class DoChildProcessTest(unittest.TestCase):
# do_child_process code assumes that calling sys.exit() will # do_child_process code assumes that calling sys.exit() will
# cause subsequent code not to be executed.) # cause subsequent code not to be executed.)
self.assertRaises( self.assertRaises(
IndentationError, self.authenticator.do_child_process, 1717, KEY) IndentationError, self.authenticator.do_child_process, 1717)
mock_exit.assert_called_once_with(1) mock_exit.assert_called_once_with(1)
mock_kill.assert_called_once_with(12345, signal.SIGUSR2) mock_kill.assert_called_once_with(12345, signal.SIGUSR2)
@@ -490,7 +486,7 @@ class DoChildProcessTest(unittest.TestCase):
sample_socket.bind.side_effect = eaccess sample_socket.bind.side_effect = eaccess
mock_socket.return_value = sample_socket mock_socket.return_value = sample_socket
self.assertRaises( self.assertRaises(
IndentationError, self.authenticator.do_child_process, 1717, KEY) IndentationError, self.authenticator.do_child_process, 1717)
mock_exit.assert_called_once_with(1) mock_exit.assert_called_once_with(1)
mock_kill.assert_called_once_with(12345, signal.SIGUSR1) mock_kill.assert_called_once_with(12345, signal.SIGUSR1)
@@ -506,7 +502,7 @@ class DoChildProcessTest(unittest.TestCase):
sample_socket.bind.side_effect = eio sample_socket.bind.side_effect = eio
mock_socket.return_value = sample_socket mock_socket.return_value = sample_socket
self.assertRaises( self.assertRaises(
socket.error, self.authenticator.do_child_process, 1717, KEY) socket.error, self.authenticator.do_child_process, 1717)
@mock.patch("letsencrypt.plugins.standalone.authenticator." @mock.patch("letsencrypt.plugins.standalone.authenticator."
"OpenSSL.SSL.Connection") "OpenSSL.SSL.Connection")
@@ -519,7 +515,7 @@ class DoChildProcessTest(unittest.TestCase):
mock_socket.return_value = sample_socket mock_socket.return_value = sample_socket
mock_connection.return_value = mock.MagicMock() mock_connection.return_value = mock.MagicMock()
self.assertRaises( self.assertRaises(
CallableExhausted, self.authenticator.do_child_process, 1717, KEY) CallableExhausted, self.authenticator.do_child_process, 1717)
mock_socket.assert_called_once_with() mock_socket.assert_called_once_with()
sample_socket.bind.assert_called_once_with(("0.0.0.0", 1717)) sample_socket.bind.assert_called_once_with(("0.0.0.0", 1717))
sample_socket.listen.assert_called_once_with(1) sample_socket.listen.assert_called_once_with(1)
@@ -538,9 +534,9 @@ class CleanupTest(unittest.TestCase):
self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None) self.authenticator = StandaloneAuthenticator(config=CONFIG, name=None)
self.achall = achallenges.DVSNI( self.achall = achallenges.DVSNI(
challb=acme_util.chall_to_challb( challb=acme_util.chall_to_challb(
challenges.DVSNI(r="whee", nonce="foononce"), "pending"), challenges.DVSNI(token=b"footoken"), "pending"),
domain="foo.example.com", key="key") domain="foo.example.com", account=mock.Mock(key="key"))
self.authenticator.tasks = {self.achall.nonce_domain: "stuff"} self.authenticator.tasks = {self.achall.token: "stuff"}
self.authenticator.child_pid = 12345 self.authenticator.child_pid = 12345
@mock.patch("letsencrypt.plugins.standalone.authenticator.os.kill") @mock.patch("letsencrypt.plugins.standalone.authenticator.os.kill")
@@ -558,8 +554,8 @@ class CleanupTest(unittest.TestCase):
self.assertRaises( self.assertRaises(
ValueError, self.authenticator.cleanup, [achallenges.DVSNI( ValueError, self.authenticator.cleanup, [achallenges.DVSNI(
challb=acme_util.chall_to_challb( challb=acme_util.chall_to_challb(
challenges.DVSNI(r="whee", nonce="badnonce"), "pending"), challenges.DVSNI(token=b"badtoken"), "pending"),
domain="bad.example.com", key="key")]) domain="bad.example.com", account=mock.Mock(key="key"))])
class MoreInfoTest(unittest.TestCase): class MoreInfoTest(unittest.TestCase):
-72
View File
@@ -1,72 +0,0 @@
"""Recovery Token Identifier Validation Challenge."""
import errno
import os
import zope.component
from acme import challenges
from letsencrypt import le_util
from letsencrypt import interfaces
class RecoveryToken(object):
"""Recovery Token Identifier Validation Challenge.
Based on draft-barnes-acme, section 6.4.
"""
def __init__(self, server, direc):
self.token_dir = os.path.join(direc, server)
def perform(self, chall):
"""Perform the Recovery Token Challenge.
:param chall: Recovery Token Challenge
:type chall: :class:`letsencrypt.achallenges.RecoveryToken`
:returns: response
:rtype: dict
"""
token_fp = os.path.join(self.token_dir, chall.domain)
if os.path.isfile(token_fp):
with open(token_fp) as token_fd:
return challenges.RecoveryTokenResponse(token=token_fd.read())
cancel, token = zope.component.getUtility(
interfaces.IDisplay).input(
"%s - Input Recovery Token: " % chall.domain)
if cancel != 1:
return challenges.RecoveryTokenResponse(token=token)
return None
def cleanup(self, chall):
"""Cleanup the saved recovery token if it exists.
:param chall: Recovery Token Challenge
:type chall: :class:`letsencrypt.achallenges.RecoveryToken`
"""
try:
le_util.safely_remove(os.path.join(self.token_dir, chall.domain))
except OSError as err:
if err.errno != errno.ENOENT:
raise
def requires_human(self, domain):
"""Indicates whether or not domain can be auto solved."""
return not os.path.isfile(os.path.join(self.token_dir, domain))
def store_token(self, domain, token):
"""Store token for later automatic use.
:param str domain: domain associated with the token
:param str token: token from authorization
"""
le_util.make_or_verify_dir(self.token_dir, 0o700, os.geteuid())
with open(os.path.join(self.token_dir, domain), "w") as token_fd:
token_fd.write(str(token))
-2
View File
@@ -63,7 +63,6 @@ class ReportNewAccountTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.config = mock.MagicMock(config_dir="/etc/letsencrypt") self.config = mock.MagicMock(config_dir="/etc/letsencrypt")
reg = messages.Registration.from_data(email="rhino@jungle.io") reg = messages.Registration.from_data(email="rhino@jungle.io")
reg = reg.update(recovery_token="ECCENTRIC INVISIBILITY RHINOCEROS")
self.acc = mock.MagicMock(regr=messages.RegistrationResource( self.acc = mock.MagicMock(regr=messages.RegistrationResource(
uri=None, new_authzr_uri=None, body=reg)) uri=None, new_authzr_uri=None, body=reg))
@@ -81,7 +80,6 @@ class ReportNewAccountTest(unittest.TestCase):
self._call() self._call()
call_list = mock_zope().add_message.call_args_list call_list = mock_zope().add_message.call_args_list
self.assertTrue(self.config.config_dir in call_list[0][0][0]) self.assertTrue(self.config.config_dir in call_list[0][0][0])
self.assertTrue(self.acc.regr.body.recovery_token in call_list[1][0][0])
self.assertTrue( self.assertTrue(
", ".join(self.acc.regr.body.emails) in call_list[1][0][0]) ", ".join(self.acc.regr.body.emails) in call_list[1][0][0])
+11 -19
View File
@@ -1,10 +1,9 @@
"""Tests for letsencrypt.achallenges.""" """Tests for letsencrypt.achallenges."""
import unittest import unittest
import OpenSSL import mock
from acme import challenges from acme import challenges
from acme import crypto_util as acme_crypto_util
from acme import jose from acme import jose
from letsencrypt.tests import acme_util from letsencrypt.tests import acme_util
@@ -15,28 +14,21 @@ class DVSNITest(unittest.TestCase):
"""Tests for letsencrypt.achallenges.DVSNI.""" """Tests for letsencrypt.achallenges.DVSNI."""
def setUp(self): def setUp(self):
self.chall = acme_util.chall_to_challb( self.challb = acme_util.chall_to_challb(acme_util.DVSNI, "pending")
challenges.DVSNI(r="r_value", nonce="12345ABCDE"), "pending") account = mock.Mock(key=jose.JWKRSA.load(
self.response = challenges.DVSNIResponse() test_util.load_vector("rsa512_key.pem")))
key = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
from letsencrypt.achallenges import DVSNI from letsencrypt.achallenges import DVSNI
self.achall = DVSNI(challb=self.chall, domain="example.com", key=key) self.achall = DVSNI(
challb=self.challb, domain="example.com", account=account)
def test_proxy(self): def test_proxy(self):
self.assertEqual(self.chall.r, self.achall.r) self.assertEqual(self.challb.token, self.achall.token)
self.assertEqual(self.chall.nonce, self.achall.nonce)
def test_gen_cert_and_response(self): def test_gen_cert_and_response(self):
cert_pem, _ = self.achall.gen_cert_and_response(s=self.response.s) response, cert_pem, key_pem = self.achall.gen_cert_and_response()
self.assertTrue(isinstance(response, challenges.DVSNIResponse))
cert = OpenSSL.crypto.load_certificate( self.assertTrue(isinstance(cert_pem, bytes))
OpenSSL.crypto.FILETYPE_PEM, cert_pem) self.assertTrue(isinstance(key_pem, bytes))
self.assertEqual(cert.get_subject().CN, "example.com")
# pylint: disable=protected-access
self.assertEqual(acme_crypto_util._pyopenssl_cert_or_req_san(cert), [
"example.com", self.chall.nonce_domain,
self.response.z_domain(self.chall)])
if __name__ == "__main__": if __name__ == "__main__":
+3 -7
View File
@@ -15,14 +15,12 @@ KEY = test_util.load_rsa_private_key('rsa512_key.pem')
SIMPLE_HTTP = challenges.SimpleHTTP( SIMPLE_HTTP = challenges.SimpleHTTP(
token="evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA") token="evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA")
DVSNI = challenges.DVSNI( DVSNI = challenges.DVSNI(
r=jose.b64decode("Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI"), token=jose.b64decode(b"evaGxfADs6pSRb2LAv9IZf17Dt3juxGJyPCt92wrDoA"))
nonce=jose.b64decode("a82d5ff8ef740d12881f6d3c2277ab2e"))
DNS = challenges.DNS(token="17817c66b60ce2e4012dfad92657527a") DNS = challenges.DNS(token="17817c66b60ce2e4012dfad92657527a")
RECOVERY_CONTACT = challenges.RecoveryContact( RECOVERY_CONTACT = challenges.RecoveryContact(
activation_url="https://example.ca/sendrecovery/a5bd99383fb0", activation_url="https://example.ca/sendrecovery/a5bd99383fb0",
success_url="https://example.ca/confirmrecovery/bb1b9928932", success_url="https://example.ca/confirmrecovery/bb1b9928932",
contact="c********n@example.com") contact="c********n@example.com")
RECOVERY_TOKEN = challenges.RecoveryToken()
POP = challenges.ProofOfPossession( POP = challenges.ProofOfPossession(
alg="RS256", nonce=jose.b64decode("eET5udtV7aoX8Xl8gYiZIA"), alg="RS256", nonce=jose.b64decode("eET5udtV7aoX8Xl8gYiZIA"),
hints=challenges.ProofOfPossession.Hints( hints=challenges.ProofOfPossession.Hints(
@@ -43,7 +41,7 @@ POP = challenges.ProofOfPossession(
) )
) )
CHALLENGES = [SIMPLE_HTTP, DVSNI, DNS, RECOVERY_CONTACT, RECOVERY_TOKEN, POP] CHALLENGES = [SIMPLE_HTTP, DVSNI, DNS, RECOVERY_CONTACT, POP]
DV_CHALLENGES = [chall for chall in CHALLENGES DV_CHALLENGES = [chall for chall in CHALLENGES
if isinstance(chall, challenges.DVChallenge)] if isinstance(chall, challenges.DVChallenge)]
CONT_CHALLENGES = [chall for chall in CHALLENGES CONT_CHALLENGES = [chall for chall in CHALLENGES
@@ -85,11 +83,9 @@ DVSNI_P = chall_to_challb(DVSNI, messages.STATUS_PENDING)
SIMPLE_HTTP_P = chall_to_challb(SIMPLE_HTTP, messages.STATUS_PENDING) SIMPLE_HTTP_P = chall_to_challb(SIMPLE_HTTP, messages.STATUS_PENDING)
DNS_P = chall_to_challb(DNS, messages.STATUS_PENDING) DNS_P = chall_to_challb(DNS, messages.STATUS_PENDING)
RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages.STATUS_PENDING) RECOVERY_CONTACT_P = chall_to_challb(RECOVERY_CONTACT, messages.STATUS_PENDING)
RECOVERY_TOKEN_P = chall_to_challb(RECOVERY_TOKEN, messages.STATUS_PENDING)
POP_P = chall_to_challb(POP, messages.STATUS_PENDING) POP_P = chall_to_challb(POP, messages.STATUS_PENDING)
CHALLENGES_P = [SIMPLE_HTTP_P, DVSNI_P, DNS_P, CHALLENGES_P = [SIMPLE_HTTP_P, DVSNI_P, DNS_P, RECOVERY_CONTACT_P, POP_P]
RECOVERY_CONTACT_P, RECOVERY_TOKEN_P, POP_P]
DV_CHALLENGES_P = [challb for challb in CHALLENGES_P DV_CHALLENGES_P = [challb for challb in CHALLENGES_P
if isinstance(challb.chall, challenges.DVChallenge)] if isinstance(challb.chall, challenges.DVChallenge)]
CONT_CHALLENGES_P = [ CONT_CHALLENGES_P = [
+13 -15
View File
@@ -19,7 +19,6 @@ TRANSLATE = {
"dvsni": "DVSNI", "dvsni": "DVSNI",
"simpleHttp": "SimpleHTTP", "simpleHttp": "SimpleHTTP",
"dns": "DNS", "dns": "DNS",
"recoveryToken": "RecoveryToken",
"recoveryContact": "RecoveryContact", "recoveryContact": "RecoveryContact",
"proofOfPossession": "ProofOfPossession", "proofOfPossession": "ProofOfPossession",
} }
@@ -41,7 +40,8 @@ class ChallengeFactoryTest(unittest.TestCase):
[messages.STATUS_PENDING]*6, False) [messages.STATUS_PENDING]*6, False)
def test_all(self): def test_all(self):
cont_c, dv_c = self.handler._challenge_factory(self.dom, range(0, 6)) cont_c, dv_c = self.handler._challenge_factory(
self.dom, range(0, len(acme_util.CHALLENGES)))
self.assertEqual( self.assertEqual(
[achall.chall for achall in cont_c], acme_util.CONT_CHALLENGES) [achall.chall for achall in cont_c], acme_util.CONT_CHALLENGES)
@@ -49,10 +49,10 @@ class ChallengeFactoryTest(unittest.TestCase):
[achall.chall for achall in dv_c], acme_util.DV_CHALLENGES) [achall.chall for achall in dv_c], acme_util.DV_CHALLENGES)
def test_one_dv_one_cont(self): def test_one_dv_one_cont(self):
cont_c, dv_c = self.handler._challenge_factory(self.dom, [1, 4]) cont_c, dv_c = self.handler._challenge_factory(self.dom, [1, 3])
self.assertEqual( self.assertEqual(
[achall.chall for achall in cont_c], [acme_util.RECOVERY_TOKEN]) [achall.chall for achall in cont_c], [acme_util.RECOVERY_CONTACT])
self.assertEqual([achall.chall for achall in dv_c], [acme_util.DVSNI]) self.assertEqual([achall.chall for achall in dv_c], [acme_util.DVSNI])
def test_unrecognized(self): def test_unrecognized(self):
@@ -80,7 +80,7 @@ class GetAuthorizationsTest(unittest.TestCase):
self.mock_dv_auth.get_chall_pref.return_value = [challenges.DVSNI] self.mock_dv_auth.get_chall_pref.return_value = [challenges.DVSNI]
self.mock_cont_auth.get_chall_pref.return_value = [ self.mock_cont_auth.get_chall_pref.return_value = [
challenges.RecoveryToken] challenges.RecoveryContact]
self.mock_cont_auth.perform.side_effect = gen_auth_resp self.mock_cont_auth.perform.side_effect = gen_auth_resp
self.mock_dv_auth.perform.side_effect = gen_auth_resp self.mock_dv_auth.perform.side_effect = gen_auth_resp
@@ -196,7 +196,7 @@ class PollChallengesTest(unittest.TestCase):
self.chall_update = {} self.chall_update = {}
for dom in self.doms: for dom in self.doms:
self.chall_update[dom] = [ self.chall_update[dom] = [
challb_to_achall(challb, "dummy_key", dom) challb_to_achall(challb, mock.Mock(key="dummy_key"), dom)
for challb in self.handler.authzr[dom].body.challenges] for challb in self.handler.authzr[dom].body.challenges]
@mock.patch("letsencrypt.auth_handler.time") @mock.patch("letsencrypt.auth_handler.time")
@@ -313,11 +313,11 @@ class GenChallengePathTest(unittest.TestCase):
self.assertTrue(self._call(challbs[::-1], prefs, None)) self.assertTrue(self._call(challbs[::-1], prefs, None))
def test_common_case_with_continuity(self): def test_common_case_with_continuity(self):
challbs = (acme_util.RECOVERY_TOKEN_P, challbs = (acme_util.POP_P,
acme_util.RECOVERY_CONTACT_P, acme_util.RECOVERY_CONTACT_P,
acme_util.DVSNI_P, acme_util.DVSNI_P,
acme_util.SIMPLE_HTTP_P) acme_util.SIMPLE_HTTP_P)
prefs = [challenges.RecoveryToken, challenges.DVSNI] prefs = [challenges.ProofOfPossession, challenges.DVSNI]
combos = acme_util.gen_combos(challbs) combos = acme_util.gen_combos(challbs)
self.assertEqual(self._call(challbs, prefs, combos), (0, 2)) self.assertEqual(self._call(challbs, prefs, combos), (0, 2))
@@ -325,21 +325,19 @@ class GenChallengePathTest(unittest.TestCase):
self.assertTrue(self._call(challbs, prefs, None)) self.assertTrue(self._call(challbs, prefs, None))
def test_full_cont_server(self): def test_full_cont_server(self):
challbs = (acme_util.RECOVERY_TOKEN_P, challbs = (acme_util.RECOVERY_CONTACT_P,
acme_util.RECOVERY_CONTACT_P,
acme_util.POP_P, acme_util.POP_P,
acme_util.DVSNI_P, acme_util.DVSNI_P,
acme_util.SIMPLE_HTTP_P, acme_util.SIMPLE_HTTP_P,
acme_util.DNS_P) acme_util.DNS_P)
# Typical webserver client that can do everything except DNS # Typical webserver client that can do everything except DNS
# Attempted to make the order realistic # Attempted to make the order realistic
prefs = [challenges.RecoveryToken, prefs = [challenges.ProofOfPossession,
challenges.ProofOfPossession,
challenges.SimpleHTTP, challenges.SimpleHTTP,
challenges.DVSNI, challenges.DVSNI,
challenges.RecoveryContact] challenges.RecoveryContact]
combos = acme_util.gen_combos(challbs) combos = acme_util.gen_combos(challbs)
self.assertEqual(self._call(challbs, prefs, combos), (0, 4)) self.assertEqual(self._call(challbs, prefs, combos), (1, 3))
# Dumb path trivial test # Dumb path trivial test
self.assertTrue(self._call(challbs, prefs, None)) self.assertTrue(self._call(challbs, prefs, None))
@@ -444,13 +442,13 @@ class ReportFailedChallsTest(unittest.TestCase):
self.dvsni_same = achallenges.DVSNI( self.dvsni_same = achallenges.DVSNI(
challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
domain="example.com", domain="example.com",
key=acme_util.KEY) account=mock.Mock(key=acme_util.KEY))
kwargs["error"] = messages.Error(typ="dnssec", detail="detail") kwargs["error"] = messages.Error(typ="dnssec", detail="detail")
self.dvsni_diff = achallenges.DVSNI( self.dvsni_diff = achallenges.DVSNI(
challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args challb=messages.ChallengeBody(**kwargs),# pylint: disable=star-args
domain="foo.bar", domain="foo.bar",
key=acme_util.KEY) account=mock.Mock(key=acme_util.KEY))
@mock.patch("letsencrypt.auth_handler.zope.component.getUtility") @mock.patch("letsencrypt.auth_handler.zope.component.getUtility")
def test_same_error_and_domain(self, mock_zope): def test_same_error_and_domain(self, mock_zope):
-2
View File
@@ -36,7 +36,6 @@ class NamespaceConfigTest(unittest.TestCase):
constants.CERT_DIR = 'certs' constants.CERT_DIR = 'certs'
constants.IN_PROGRESS_DIR = '../p' constants.IN_PROGRESS_DIR = '../p'
constants.KEY_DIR = 'keys' constants.KEY_DIR = 'keys'
constants.REC_TOKEN_DIR = '/r'
constants.TEMP_CHECKPOINT_DIR = 't' constants.TEMP_CHECKPOINT_DIR = 't'
self.assertEqual( self.assertEqual(
@@ -47,7 +46,6 @@ class NamespaceConfigTest(unittest.TestCase):
self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new') self.config.cert_key_backup, '/tmp/foo/c/acme-server.org:443/new')
self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p') self.assertEqual(self.config.in_progress_dir, '/tmp/foo/../p')
self.assertEqual(self.config.key_dir, '/tmp/config/keys') self.assertEqual(self.config.key_dir, '/tmp/config/keys')
self.assertEqual(self.config.rec_token_dir, '/r')
self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t') self.assertEqual(self.config.temp_checkpoint_dir, '/tmp/foo/t')
+10 -47
View File
@@ -17,54 +17,30 @@ class PerformTest(unittest.TestCase):
self.auth = ContinuityAuthenticator( self.auth = ContinuityAuthenticator(
mock.MagicMock(server="demo_server.org"), None) mock.MagicMock(server="demo_server.org"), None)
self.auth.rec_token.perform = mock.MagicMock(
name="rec_token_perform", side_effect=gen_client_resp)
self.auth.proof_of_pos.perform = mock.MagicMock( self.auth.proof_of_pos.perform = mock.MagicMock(
name="proof_of_pos_perform", side_effect=gen_client_resp) name="proof_of_pos_perform", side_effect=gen_client_resp)
def test_rec_token1(self): def test_pop(self):
token = achallenges.RecoveryToken(challb=None, domain="0")
responses = self.auth.perform([token])
self.assertEqual(responses, ["RecoveryToken0"])
def test_rec_token5(self):
tokens = []
for i in xrange(5):
tokens.append(achallenges.RecoveryToken(challb=None, domain=str(i)))
responses = self.auth.perform(tokens)
self.assertEqual(len(responses), 5)
for i in xrange(5):
self.assertEqual(responses[i], "RecoveryToken%d" % i)
def test_pop_and_rec_token(self):
achalls = [] achalls = []
for i in xrange(4): for i in xrange(4):
if i % 2 == 0: achalls.append(achallenges.ProofOfPossession(
achalls.append(achallenges.RecoveryToken(challb=None, challb=None, domain=str(i)))
domain=str(i)))
else:
achalls.append(achallenges.ProofOfPossession(challb=None,
domain=str(i)))
responses = self.auth.perform(achalls) responses = self.auth.perform(achalls)
self.assertEqual(len(responses), 4) self.assertEqual(len(responses), 4)
for i in xrange(4): for i in xrange(4):
if i % 2 == 0: self.assertEqual(responses[i], "ProofOfPossession%d" % i)
self.assertEqual(responses[i], "RecoveryToken%d" % i)
else:
self.assertEqual(responses[i], "ProofOfPossession%d" % i)
def test_unexpected(self): def test_unexpected(self):
self.assertRaises( self.assertRaises(
errors.ContAuthError, self.auth.perform, [ errors.ContAuthError, self.auth.perform, [
achallenges.DVSNI(challb=None, domain="0", key="invalid_key")]) achallenges.DVSNI(challb=None, domain="0",
account=mock.Mock(key="invalid_key"))])
def test_chall_pref(self): def test_chall_pref(self):
self.assertEqual( self.assertEqual(
self.auth.get_chall_pref("example.com"), self.auth.get_chall_pref("example.com"),
[challenges.ProofOfPossession, challenges.RecoveryToken]) [challenges.ProofOfPossession])
class CleanupTest(unittest.TestCase): class CleanupTest(unittest.TestCase):
@@ -75,24 +51,11 @@ class CleanupTest(unittest.TestCase):
self.auth = ContinuityAuthenticator( self.auth = ContinuityAuthenticator(
mock.MagicMock(server="demo_server.org"), None) mock.MagicMock(server="demo_server.org"), None)
self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup")
self.auth.rec_token.cleanup = self.mock_cleanup
def test_rec_token2(self):
token1 = achallenges.RecoveryToken(challb=None, domain="0")
token2 = achallenges.RecoveryToken(challb=None, domain="1")
self.auth.cleanup([token1, token2])
self.assertEqual(self.mock_cleanup.call_args_list,
[mock.call(token1), mock.call(token2)])
def test_unexpected(self): def test_unexpected(self):
token = achallenges.RecoveryToken(challb=None, domain="0") unexpected = achallenges.DVSNI(
unexpected = achallenges.DVSNI(challb=None, domain="0", key="dummy_key") challb=None, domain="0", account=mock.Mock("dummy_key"))
self.assertRaises(errors.ContAuthError, self.auth.cleanup, [unexpected])
self.assertRaises(
errors.ContAuthError, self.auth.cleanup, [token, unexpected])
def gen_client_resp(chall): def gen_client_resp(chall):
+31
View File
@@ -227,6 +227,37 @@ class UniqueLineageNameTest(unittest.TestCase):
self.assertRaises(OSError, self._call, "wow") self.assertRaises(OSError, self._call, "wow")
class SafelyRemoveTest(unittest.TestCase):
"""Tests for letsencrypt.le_util.safely_remove."""
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.path = os.path.join(self.tmp, "foo")
def tearDown(self):
shutil.rmtree(self.tmp)
def _call(self):
from letsencrypt.le_util import safely_remove
return safely_remove(self.path)
def test_exists(self):
with open(self.path, "w"):
pass # just create the file
self._call()
self.assertFalse(os.path.exists(self.path))
def test_missing(self):
self._call()
# no error, yay!
self.assertFalse(os.path.exists(self.path))
@mock.patch("letsencrypt.le_util.os.remove")
def test_other_error_passthrough(self, mock_remove):
mock_remove.side_effect = OSError
self.assertRaises(OSError, self._call)
class SafeEmailTest(unittest.TestCase): class SafeEmailTest(unittest.TestCase):
"""Test safe_email.""" """Test safe_email."""
@classmethod @classmethod
-80
View File
@@ -1,80 +0,0 @@
"""Tests for recovery_token.py."""
import os
import unittest
import shutil
import tempfile
import mock
from acme import challenges
from letsencrypt import achallenges
class RecoveryTokenTest(unittest.TestCase):
def setUp(self):
from letsencrypt.recovery_token import RecoveryToken
server = "demo_server"
self.base_dir = tempfile.mkdtemp("tokens")
self.token_dir = os.path.join(self.base_dir, server)
self.rec_token = RecoveryToken(server, self.base_dir)
def tearDown(self):
shutil.rmtree(self.base_dir)
def test_store_token(self):
self.rec_token.store_token("example.com", 111)
path = os.path.join(self.token_dir, "example.com")
self.assertTrue(os.path.isfile(path))
with open(path) as token_fd:
self.assertEqual(token_fd.read(), "111")
def test_requires_human(self):
self.rec_token.store_token("example2.com", 222)
self.assertFalse(self.rec_token.requires_human("example2.com"))
self.assertTrue(self.rec_token.requires_human("example3.com"))
def test_cleanup(self):
self.rec_token.store_token("example3.com", 333)
self.assertFalse(self.rec_token.requires_human("example3.com"))
self.rec_token.cleanup(achallenges.RecoveryToken(
challb=challenges.RecoveryToken(), domain="example3.com"))
self.assertTrue(self.rec_token.requires_human("example3.com"))
# Shouldn't throw an error
self.rec_token.cleanup(achallenges.RecoveryToken(
challb=None, domain="example4.com"))
# SHOULD throw an error (OSError other than nonexistent file)
self.assertRaises(
OSError, self.rec_token.cleanup,
achallenges.RecoveryToken(
challb=None, domain=("a" + "r" * 10000 + ".com")))
def test_perform_stored(self):
self.rec_token.store_token("example4.com", 444)
response = self.rec_token.perform(
achallenges.RecoveryToken(
challb=challenges.RecoveryToken(), domain="example4.com"))
self.assertEqual(
response, challenges.RecoveryTokenResponse(token="444"))
@mock.patch("letsencrypt.recovery_token.zope.component.getUtility")
def test_perform_not_stored(self, mock_input):
mock_input().input.side_effect = [(0, "555"), (1, "000")]
response = self.rec_token.perform(
achallenges.RecoveryToken(
challb=challenges.RecoveryToken(), domain="example5.com"))
self.assertEqual(
response, challenges.RecoveryTokenResponse(token="555"))
response = self.rec_token.perform(
achallenges.RecoveryToken(
challb=challenges.RecoveryToken(), domain="example6.com"))
self.assertTrue(response is None)
if __name__ == "__main__":
unittest.main() # pragma: no cover