diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py index f9efdf559..9b4cd957a 100644 --- a/letsencrypt/client/apache/dvsni.py +++ b/letsencrypt/client/apache/dvsni.py @@ -18,7 +18,7 @@ class ApacheDvsni(object): :ivar dvsni_chall: Data required for challenges. where DvsniChall tuples have the following fields `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) - `key` (:class:`letsencrypt.client.client.Client.Key`) + `key` (:class:`letsencrypt.client.le_util.Key`) :type dvsni_chall: `list` of :class:`letsencrypt.client.challenge_util.DvsniChall` diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py index 1ccb6b1ce..11d940e94 100644 --- a/letsencrypt/client/auth_handler.py +++ b/letsencrypt/client/auth_handler.py @@ -28,7 +28,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :ivar list domains: list of str domains to get authorization :ivar dict authkey: Authorized Keys for each domain. - values are of type :class:`letsencrypt.client.client.Client.Key` + values are of type :class:`letsencrypt.client.le_util.Key` :ivar dict responses: keys: domain, values: list of dict responses :ivar dict msgs: ACME Challenge messages with domain as a key :ivar dict paths: optimal path for authorization. eg. paths[domain] @@ -61,7 +61,7 @@ class AuthHandler(object): # pylint: disable=too-many-instance-attributes :type msg: :class:`letsencrypt.acme.message.Challenge` :param authkey: authorized key for the challenge - :type authkey: :class:`letsencrypt.client.client.Client.Key` + :type authkey: :class:`letsencrypt.client.le_util.Key` """ if domain in self.domains: diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py index 322cfdee8..118f6d6aa 100644 --- a/letsencrypt/client/challenge_util.py +++ b/letsencrypt/client/challenge_util.py @@ -36,7 +36,7 @@ def dvsni_gen_cert(name, r_b64, nonce, key): :param str nonce: hex value of nonce :param key: Key to perform challenge - :type key: :class:`letsencrypt.client.client.Client.Key` + :type key: :class:`letsencrypt.client.le_util.Key` :returns: tuple of (cert_pem, s) where cert_pem is the certificate in pem form diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a2757cd6b..196d260bd 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,5 +1,4 @@ """ACME protocol client class and helper functions.""" -import collections import csv import logging import os @@ -32,7 +31,7 @@ class Client(object): :type network: :class:`letsencrypt.client.network.Network` :ivar authkey: Authorization Key - :type authkey: :class:`letsencrypt.client.client.Client.Key` + :type authkey: :class:`letsencrypt.client.le_util.Key` :ivar auth_handler: Object that supports the IAuthenticator interface. auth_handler contains both a dv_authenticator and a client_authenticator @@ -47,10 +46,6 @@ class Client(object): """ zope.interface.implements(interfaces.IAuthenticator) - Key = collections.namedtuple("Key", "file pem") - # Note: form is the type of data, "pem" or "der" - CSR = collections.namedtuple("CSR", "file data form") - def __init__(self, config, authkey, dv_auth, installer): """Initialize a client. @@ -182,7 +177,7 @@ class Client(object): :param list domains: list of domains to install the certificate :param privkey: private key for certificate - :type privkey: :class:`Key` + :type privkey: :class:`letsencrypt.client.le_util.Key` :param str cert_file: certificate file path :param str chain_file: chain file path @@ -309,10 +304,10 @@ def validate_key_csr(privkey, csr=None): If csr is left as None, only the key will be validated. :param privkey: Key associated with CSR - :type privkey: :class:`letsencrypt.client.client.Client.Key` + :type privkey: :class:`letsencrypt.client.le_util.Key` :param csr: CSR - :type csr: :class:`letsencrypt.client.client.Client.CSR` + :type csr: :class:`letsencrypt.client.le_util.CSR` :raises LetsEncryptClientError: if validation fails @@ -329,7 +324,7 @@ def validate_key_csr(privkey, csr=None): if csr: if csr.form == "der": csr_obj = M2Crypto.X509.load_request_der_string(csr.data) - csr = Client.CSR(csr.file, csr_obj.as_pem(), "der") + csr = le_util.CSR(csr.file, csr_obj.as_pem(), "der") # If CSR is provided, it must be readable and valid. if csr.data and not crypto_util.valid_csr(csr.data): @@ -371,7 +366,7 @@ def init_key(key_size, key_dir): logging.info("Generating key (%d bits): %s", key_size, key_filename) - return Client.Key(key_filename, key_pem) + return le_util.Key(key_filename, key_pem) def init_csr(privkey, names, cert_dir): @@ -391,14 +386,14 @@ def init_csr(privkey, names, cert_dir): logging.info("Creating CSR: %s", csr_filename) - return Client.CSR(csr_filename, csr_der, "der") + return le_util.CSR(csr_filename, csr_der, "der") def csr_pem_to_der(csr): """Convert pem CSR to der.""" csr_obj = M2Crypto.X509.load_request_string(csr.data) - return Client.CSR(csr.file, csr_obj.as_der(), "der") + return le_util.CSR(csr.file, csr_obj.as_der(), "der") # This should be controlled by commandline parameters diff --git a/letsencrypt/client/constants.py b/letsencrypt/client/constants.py index 3652face7..e30a4b725 100644 --- a/letsencrypt/client/constants.py +++ b/letsencrypt/client/constants.py @@ -45,6 +45,9 @@ APACHE_REWRITE_HTTPS_ARGS = [ """Apache rewrite rule arguments used for redirections to https vhost""" +DVSNI_CHALLENGE_PORT = 443 +"""Port to perform DVSNI challenge.""" + DVSNI_DOMAIN_SUFFIX = ".acme.invalid" """Suffix appended to domains in DVSNI validation.""" diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index 4337c91c9..8b4b51536 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -1,4 +1,5 @@ """Utilities for all Let's Encrypt.""" +import collections import errno import os import stat @@ -6,6 +7,10 @@ import stat from letsencrypt.client import errors +Key = collections.namedtuple("Key", "file pem") +# Note: form is the type of data, "pem" or "der" +CSR = collections.namedtuple("CSR", "file data form") + def make_or_verify_dir(directory, mode=0o755, uid=0): """Make sure directory exists with proper permissions. @@ -27,8 +32,8 @@ def make_or_verify_dir(directory, mode=0o755, uid=0): if exception.errno == errno.EEXIST: if not check_permissions(directory, mode, uid): raise errors.LetsEncryptClientError( - '%s exists, but does not have the proper ' - 'permissions or owner' % directory) + "%s exists, but does not have the proper " + "permissions or owner" % directory) else: raise @@ -63,7 +68,7 @@ def unique_file(path, mode=0o777): fname = os.path.join(path, "%04d_%s" % (count, tail)) try: file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) - return os.fdopen(file_d, 'w'), fname + return os.fdopen(file_d, "w"), fname except OSError: pass count += 1 diff --git a/letsencrypt/client/standalone_authenticator.py b/letsencrypt/client/standalone_authenticator.py new file mode 100755 index 000000000..a1b1daa58 --- /dev/null +++ b/letsencrypt/client/standalone_authenticator.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""An authenticator that doesn't rely on any existing server program. + +This authenticator creates its own ephemeral TCP listener on the specified +port in order to respond to incoming DVSNI challenges from the certificate +authority.""" + +import os +import signal +import socket +import sys +import time + +import Crypto.Random +import OpenSSL.crypto +import OpenSSL.SSL +import zope.component +import zope.interface + +from letsencrypt.client import challenge_util +from letsencrypt.client import constants +from letsencrypt.client import interfaces + + +class StandaloneAuthenticator(object): + # pylint: disable=too-many-instance-attributes + """The StandaloneAuthenticator class itself. + + This authenticator can be invoked by the Let's Encrypt client + according to the IAuthenticator API interface. It creates a local + TCP listener on a specified port and satisfies DVSNI challenges.""" + zope.interface.implements(interfaces.IAuthenticator) + + def __init__(self): + self.child_pid = None + self.parent_pid = os.getpid() + self.subproc_state = None + self.tasks = {} + self.sock = None + self.connection = None + self.private_key = None + self.ssl_conn = None + + def client_signal_handler(self, sig, unused_frame): + """Signal handler for the parent process. + + This handler receives inter-process communication from the + child process in the form of Unix signals. + + :param int sig: Which signal the process received.""" + # subprocess → client READY : SIGIO + # subprocess → client INUSE : SIGUSR1 + # subprocess → client CANTBIND: SIGUSR2 + if sig == signal.SIGIO: + self.subproc_state = "ready" + elif sig == signal.SIGUSR1: + self.subproc_state = "inuse" + elif sig == signal.SIGUSR2: + self.subproc_state = "cantbind" + else: + # NOTREACHED + raise ValueError("Unexpected signal in signal handler") + + def subproc_signal_handler(self, sig, unused_frame): + """Signal handler for the child process. + + This handler receives inter-process communication from the parent + process in the form of Unix signals. + + :param int sig: Which signal the process received.""" + # client → subprocess CLEANUP : SIGINT + if sig == signal.SIGINT: + try: + self.ssl_conn.shutdown() + self.ssl_conn.close() + except BaseException: + # There might not even be any currently active SSL connection. + pass + try: + self.connection.close() + except BaseException: + # There might not even be any currently active connection. + pass + try: + self.sock.close() + except BaseException: + # Various things can go wrong in the course of closing these + # connections, but none of them can clearly be usefully + # reported here and none of them should impede us from + # exiting as gracefully as possible. + pass + os.kill(self.parent_pid, signal.SIGUSR1) + sys.exit(0) + + def sni_callback(self, connection): + """Used internally to respond to incoming SNI names. + + This method will set a new OpenSSL context object for this + connection when an incoming connection provides an SNI name + (in order to serve the appropriate certificate, if any). + + :param OpenSSL.Connection connection: The TLS connection object + on which the SNI extension was received.""" + + sni_name = connection.get_servername() + if sni_name in self.tasks: + pem_cert = self.tasks[sni_name] + else: + # TODO: Should we really present a certificate if we get an + # unexpected SNI name? Or should we just disconnect? + pem_cert = self.tasks.values()[0] + cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + pem_cert) + new_ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD) + new_ctx.set_verify(OpenSSL.SSL.VERIFY_NONE, lambda: False) + new_ctx.use_certificate(cert) + new_ctx.use_privatekey(self.private_key) + connection.set_context(new_ctx) + + def do_parent_process(self, port, delay_amount=5): + """Perform the parent process side of the TCP listener task. + + This should only be called by start_listener(). We will wait + up to delay_amount seconds to hear from the child process via + a signal. + + :param int port: Which TCP port to bind. + :param float delay_amount: How long in seconds to wait for the + subprocess to notify us whether it succeeded. + + :returns: True or False according to whether we were notified + that the child process succeeded or failed in binding the port.""" + + signal.signal(signal.SIGIO, self.client_signal_handler) + signal.signal(signal.SIGUSR1, self.client_signal_handler) + signal.signal(signal.SIGUSR2, self.client_signal_handler) + display = zope.component.getUtility(interfaces.IDisplay) + start_time = time.time() + while time.time() < start_time + delay_amount: + if self.subproc_state == "ready": + return True + if self.subproc_state == "inuse": + display.generic_notification( + "Could not bind TCP port {0} because it is already in " + "use it is already in use by another process on this " + "system (such as a web server).".format(port)) + return False + if self.subproc_state == "cantbind": + display.generic_notification( + "Could not bind TCP port {0} because you don't have " + "the appropriate permissions (for example, you " + "aren't running this program as " + "root).".format(port)) + return False + time.sleep(0.1) + display.generic_notification( + "Subprocess unexpectedly timed out while trying to bind TCP " + "port {0}.".format(port)) + return False + + def do_child_process(self, port, key): + """Perform the child process side of the TCP listener task. + + This should only be called by start_listener(). + + Normally does not return; instead, the child process exits from + within this function or from within the child process signal + handler. + + :param int port: Which TCP port to bind. + :param le_util.Key key: The private key to use to respond to + DVSNI challenge requests.""" + signal.signal(signal.SIGINT, self.subproc_signal_handler) + self.sock = socket.socket() + try: + self.sock.bind(("0.0.0.0", port)) + except socket.error, error: + if error.errno == socket.errno.EACCES: + # Signal permissions denied to bind TCP port + os.kill(self.parent_pid, signal.SIGUSR2) + elif error.errno == socket.errno.EADDRINUSE: + # Signal TCP port is already in use + os.kill(self.parent_pid, signal.SIGUSR1) + else: + # XXX: How to handle unknown errors in binding? + raise error + sys.exit(1) + # XXX: We could use poll mechanism to handle simultaneous + # XXX: rather than sequential inbound TCP connections here + self.sock.listen(1) + # Signal that we've successfully bound TCP port + os.kill(self.parent_pid, signal.SIGIO) + self.private_key = OpenSSL.crypto.load_privatekey( + OpenSSL.crypto.FILETYPE_PEM, key.pem) + + while True: + self.connection, _ = self.sock.accept() + + # The code below uses the PyOpenSSL bindings to respond to + # the client. This may expose us to bugs and vulnerabilities + # in OpenSSL (and creates additional dependencies). + ctx = OpenSSL.SSL.Context(OpenSSL.SSL.TLSv1_METHOD) + ctx.set_verify(OpenSSL.SSL.VERIFY_NONE, lambda: False) + pem_cert = self.tasks.values()[0] + first_cert = OpenSSL.crypto.load_certificate( + OpenSSL.crypto.FILETYPE_PEM, pem_cert) + ctx.use_certificate(first_cert) + ctx.use_privatekey(self.private_key) + ctx.set_cipher_list("HIGH") + ctx.set_tlsext_servername_callback(self.sni_callback) + self.ssl_conn = OpenSSL.SSL.Connection(ctx, self.connection) + self.ssl_conn.set_accept_state() + self.ssl_conn.do_handshake() + self.ssl_conn.shutdown() + self.ssl_conn.close() + + def start_listener(self, port, key): + """Create a child process which will start a TCP listener on the + specified port to perform the specified DVSNI challenges. + + :param int port: The TCP port to bind. + :param le_util.Key key: The private key to use to respond to + DVSNI challenge requests. + :returns: True or False to indicate success or failure creating + the subprocess. + """ + fork_result = os.fork() + Crypto.Random.atfork() + if fork_result: + # PARENT process (still the Let's Encrypt client process) + self.child_pid = fork_result + # do_parent_process() can return True or False to indicate + # reported success or failure creating the listener. + return self.do_parent_process(port) + else: + # CHILD process (the TCP listener subprocess) + self.child_pid = os.getpid() + # do_child_process() is normally not expected to return but + # should terminate via sys.exit(). + return self.do_child_process(port, key) + + # IAuthenticator method implementations follow + + def get_chall_pref(self, unused_domain): + # pylint: disable=no-self-use + """IAuthenticator interface method get_chall_pref. + + Return a list of challenge types that this authenticator + can perform for this domain. In the case of the + StandaloneAuthenticator, the only challenge type that can ever + be performed is dvsni. + + :returns: A list containing only 'dvsni'.""" + return ["dvsni"] + + def perform(self, chall_list): + """IAuthenticator interface method perform. + + Attempt to perform the + specified challenges, returning the status of each. For the + StandaloneAuthenticator, because there is no convenient way to add + additional requests, this should only be invoked once; subsequent + invocations are an error. To perform validations for multiple + independent sets of domains, a separate StandaloneAuthenticator + should be instantiated. + + :param list chall_list: A list of the the challenge objects to + be attempted by this authenticator. + :returns: A list in the same order containing, in each position, + the successfully configured challenge, False, or None.""" + if self.child_pid or self.tasks: + # We should not be willing to continue with perform + # if there were existing pending challenges. + raise ValueError(".perform() was called with pending tasks!") + results_if_success = [] + results_if_failure = [] + if not chall_list or not isinstance(chall_list, list): + raise ValueError(".perform() was called without challenge list") + for chall in chall_list: + if isinstance(chall, challenge_util.DvsniChall): + # We will attempt to do it + name, r_b64 = chall.domain, chall.r_b64 + nonce, key = chall.nonce, chall.key + cert, s_b64 = challenge_util.dvsni_gen_cert( + name, r_b64, nonce, key) + self.tasks[nonce + constants.DVSNI_DOMAIN_SUFFIX] = cert + results_if_success.append({"type": "dvsni", "s": s_b64}) + results_if_failure.append(None) + else: + # We will not attempt to do this challenge because it + # is not a type we can handle + results_if_success.append(False) + results_if_failure.append(False) + if not self.tasks: + raise ValueError("nothing for .perform() to do") + # Try to do the authentication; note that this creates + # the listener subprocess via os.fork() + if self.start_listener(constants.DVSNI_CHALLENGE_PORT, key): + return results_if_success + else: + # TODO: This should probably raise a DVAuthError exception + # rather than returning a list of None objects. + return results_if_failure + + def cleanup(self, chall_list): + """IAuthenticator interface method cleanup. + + Remove each of the specified challenges from the list of + challenges that still need to be performed. (In the case of + the StandaloneAuthenticator, if some challenges are removed + from the list, the authenticator socket will still respond to + those challenges.) Once all challenges have been removed from + the list, the listener is deactivated and stops listening. + + :param list chall_list: A list of the the challenge objects to + be deactivated.""" + # Remove this from pending tasks list + for chall in chall_list: + assert isinstance(chall, challenge_util.DvsniChall) + nonce = chall.nonce + if nonce + constants.DVSNI_DOMAIN_SUFFIX in self.tasks: + del self.tasks[nonce + constants.DVSNI_DOMAIN_SUFFIX] + else: + # Could not find the challenge to remove! + raise ValueError("could not find the challenge to remove") + if self.child_pid and not self.tasks: + # There are no remaining challenges, so + # try to shutdown self.child_pid cleanly. + # TODO: ignore any signals from child during this process + os.kill(self.child_pid, signal.SIGINT) + time.sleep(1) + # TODO: restore original signal handlers in parent process + # by resetting their actions to SIG_DFL + # print "TCP listener subprocess has been told to shut down" diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py index fc71dfbee..9ed56f89d 100644 --- a/letsencrypt/client/tests/apache/configurator_test.py +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -7,8 +7,8 @@ import unittest import mock from letsencrypt.client import challenge_util -from letsencrypt.client import client from letsencrypt.client import errors +from letsencrypt.client import le_util from letsencrypt.client.apache import configurator from letsencrypt.client.apache import obj @@ -164,7 +164,7 @@ class TwoVhost80Test(util.ApacheTest): def test_perform(self, mock_restart, mock_dvsni_perform): # Only tests functionality specific to configurator.perform # Note: As more challenges are offered this will have to be expanded - auth_key = client.Client.Key(self.rsa256_file, self.rsa256_pem) + auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) chall1 = challenge_util.DvsniChall( "encryption-example.demo", "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py index 848652f67..7fbce9cbb 100644 --- a/letsencrypt/client/tests/apache/dvsni_test.py +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -6,8 +6,9 @@ import shutil import mock from letsencrypt.client import challenge_util + from letsencrypt.client import constants -from letsencrypt.client import client +from letsencrypt.client import le_util from letsencrypt.client.tests.apache import util @@ -33,7 +34,7 @@ class DvsniPerformTest(util.ApacheTest): rsa256_pem = pkg_resources.resource_string( "letsencrypt.client.tests", 'testdata/rsa256_key.pem') - auth_key = client.Client.Key(rsa256_file, rsa256_pem) + auth_key = le_util.Key(rsa256_file, rsa256_pem) self.challs = [] self.challs.append(challenge_util.DvsniChall( "encryption-example.demo", diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py index c40ca053c..7c6d1035f 100644 --- a/letsencrypt/client/tests/challenge_util_test.py +++ b/letsencrypt/client/tests/challenge_util_test.py @@ -10,7 +10,7 @@ from letsencrypt.acme import jose from letsencrypt.client import challenge_util from letsencrypt.client import constants -from letsencrypt.client import client +from letsencrypt.client import le_util class DvsniGenCertTest(unittest.TestCase): @@ -24,7 +24,7 @@ class DvsniGenCertTest(unittest.TestCase): r_b64 = jose.b64encode(dvsni_r) pem = pkg_resources.resource_string( __name__, os.path.join("testdata", "rsa256_key.pem")) - key = client.Client.Key("path", pem) + key = le_util.Key("path", pem) nonce = "12345ABCDE" cert_pem, s_b64 = self._call(domain, r_b64, nonce, key) diff --git a/letsencrypt/client/tests/standalone_authenticator_test.py b/letsencrypt/client/tests/standalone_authenticator_test.py new file mode 100644 index 000000000..e28ce2c45 --- /dev/null +++ b/letsencrypt/client/tests/standalone_authenticator_test.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python + +"""Tests for standalone_authenticator.py.""" +import mock +import unittest + +import os +import pkg_resources +import signal +import socket + +import OpenSSL.crypto +import OpenSSL.SSL + +from letsencrypt.acme import jose + +from letsencrypt.client import challenge_util +from letsencrypt.client import le_util + + +# Classes based on to allow interrupting infinite loop under test +# after one iteration, based on. +# http://igorsobreira.com/2013/03/17/testing-infinite-loops.html + +class SocketAcceptOnlyNTimes(object): + # pylint: disable=too-few-public-methods + """ + Callable that will raise `CallableExhausted` + exception after `limit` calls, modified to also return + a tuple simulating the return values of a socket.accept() + call + """ + def __init__(self, limit): + self.limit = limit + self.calls = 0 + + def __call__(self): + self.calls += 1 + if self.calls > self.limit: + raise CallableExhausted + # Modified here for a single use as socket.accept() + return (mock.MagicMock(), "ignored") + +class CallableExhausted(Exception): + # pylint: disable=too-few-public-methods + """Exception raised when a method is called more than the + specified number of times.""" + + +class ChallPrefTest(unittest.TestCase): + """Tests for chall_pref() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + def test_chall_pref(self): + self.assertEqual( + self.authenticator.get_chall_pref("example.com"), ["dvsni"]) + + +class SNICallbackTest(unittest.TestCase): + """Tests for sni_callback() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + name, r_b64 = "example.com", jose.b64encode("x" * 32) + test_key = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + nonce, key = "abcdef", le_util.Key("foo", test_key) + self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0] + private_key = OpenSSL.crypto.load_privatekey( + OpenSSL.crypto.FILETYPE_PEM, key.pem) + self.authenticator.private_key = private_key + self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} + self.authenticator.child_pid = 12345 + + def test_real_servername(self): + connection = mock.MagicMock() + connection.get_servername.return_value = "abcdef.acme.invalid" + self.authenticator.sni_callback(connection) + self.assertEqual(connection.set_context.call_count, 1) + called_ctx = connection.set_context.call_args[0][0] + self.assertTrue(isinstance(called_ctx, OpenSSL.SSL.Context)) + + def test_fake_servername(self): + """Test behavior of SNI callback when an unexpected name is received. + + (Currently the expected behavior in this case is to return the + "first" certificate with which the listener was configured, + although they are stored in an unordered data structure so + this might not be the one that was first in the challenge list + passed to the perform method. In the future, this might result + in dropping the connection instead.)""" + connection = mock.MagicMock() + connection.get_servername.return_value = "example.com" + self.authenticator.sni_callback(connection) + self.assertEqual(connection.set_context.call_count, 1) + called_ctx = connection.set_context.call_args[0][0] + self.assertTrue(isinstance(called_ctx, OpenSSL.SSL.Context)) + +class ClientSignalHandlerTest(unittest.TestCase): + """Tests for client_signal_handler() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} + self.authenticator.child_pid = 12345 + + def test_client_signal_handler(self): + self.assertTrue(self.authenticator.subproc_state is None) + self.authenticator.client_signal_handler(signal.SIGIO, None) + self.assertEqual(self.authenticator.subproc_state, "ready") + + self.authenticator.client_signal_handler(signal.SIGUSR1, None) + self.assertEqual(self.authenticator.subproc_state, "inuse") + + self.authenticator.client_signal_handler(signal.SIGUSR2, None) + self.assertEqual(self.authenticator.subproc_state, "cantbind") + + # Testing the unreached path for a signal other than these + # specified (which can't occur in normal use because this + # function is only set as a signal handler for the above three + # signals). + self.assertRaises( + ValueError, self.authenticator.client_signal_handler, + signal.SIGPIPE, None) + + +class SubprocSignalHandlerTest(unittest.TestCase): + """Tests for subproc_signal_handler() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} + self.authenticator.child_pid = 12345 + self.authenticator.parent_pid = 23456 + + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + def test_subproc_signal_handler(self, mock_exit, mock_kill): + self.authenticator.ssl_conn = mock.MagicMock() + self.authenticator.connection = mock.MagicMock() + self.authenticator.sock = mock.MagicMock() + self.authenticator.subproc_signal_handler(signal.SIGINT, None) + self.assertEquals(self.authenticator.ssl_conn.shutdown.call_count, 1) + self.assertEquals(self.authenticator.ssl_conn.close.call_count, 1) + self.assertEquals(self.authenticator.connection.close.call_count, 1) + self.assertEquals(self.authenticator.sock.close.call_count, 1) + mock_kill.assert_called_once_with( + self.authenticator.parent_pid, signal.SIGUSR1) + mock_exit.assert_called_once_with(0) + + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + def test_subproc_signal_handler_trouble(self, mock_exit, mock_kill): + """Test attempting to shut down a non-existent connection. + + (This could occur because none was established or active at the + time the signal handler tried to perform the cleanup).""" + self.authenticator.ssl_conn = mock.MagicMock() + self.authenticator.connection = mock.MagicMock() + self.authenticator.sock = mock.MagicMock() + # AttributeError simulates the case where one of these properties + # is None because no connection exists. We raise it for + # ssl_conn.close() instead of ssl_conn.shutdown() for better code + # coverage. + self.authenticator.ssl_conn.close.side_effect = AttributeError("!") + self.authenticator.connection.close.side_effect = AttributeError("!") + self.authenticator.sock.close.side_effect = AttributeError("!") + self.authenticator.subproc_signal_handler(signal.SIGINT, None) + self.assertEquals(self.authenticator.ssl_conn.shutdown.call_count, 1) + self.assertEquals(self.authenticator.ssl_conn.close.call_count, 1) + self.assertEquals(self.authenticator.connection.close.call_count, 1) + self.assertEquals(self.authenticator.sock.close.call_count, 1) + mock_kill.assert_called_once_with( + self.authenticator.parent_pid, signal.SIGUSR1) + mock_exit.assert_called_once_with(0) + + +class PerformTest(unittest.TestCase): + """Tests for perform() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + def test_can_perform(self): + """What happens if start_listener() returns True.""" + test_key = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + key = le_util.Key("something", test_key) + chall1 = challenge_util.DvsniChall( + "foo.example.com", "whee", "foononce", key) + chall2 = challenge_util.DvsniChall( + "bar.example.com", "whee", "barnonce", key) + bad_chall = ("This", "Represents", "A Non-DVSNI", "Challenge") + self.authenticator.start_listener = mock.Mock() + self.authenticator.start_listener.return_value = True + result = self.authenticator.perform([chall1, chall2, bad_chall]) + self.assertEqual(len(self.authenticator.tasks), 2) + self.assertTrue( + self.authenticator.tasks.has_key("foononce.acme.invalid")) + self.assertTrue( + self.authenticator.tasks.has_key("barnonce.acme.invalid")) + self.assertTrue(isinstance(result, list)) + self.assertEqual(len(result), 3) + self.assertTrue(isinstance(result[0], dict)) + self.assertTrue(isinstance(result[1], dict)) + self.assertFalse(result[2]) + self.assertTrue(result[0].has_key("s")) + self.assertTrue(result[1].has_key("s")) + self.authenticator.start_listener.assert_called_once_with(443, key) + + def test_cannot_perform(self): + """What happens if start_listener() returns False.""" + test_key = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + key = le_util.Key("something", test_key) + chall1 = challenge_util.DvsniChall( + "foo.example.com", "whee", "foononce", key) + chall2 = challenge_util.DvsniChall( + "bar.example.com", "whee", "barnonce", key) + bad_chall = ("This", "Represents", "A Non-DVSNI", "Challenge") + self.authenticator.start_listener = mock.Mock() + self.authenticator.start_listener.return_value = False + result = self.authenticator.perform([chall1, chall2, bad_chall]) + self.assertEqual(len(self.authenticator.tasks), 2) + self.assertTrue( + self.authenticator.tasks.has_key("foononce.acme.invalid")) + self.assertTrue( + self.authenticator.tasks.has_key("barnonce.acme.invalid")) + self.assertTrue(isinstance(result, list)) + self.assertEqual(len(result), 3) + self.assertEqual(result, [None, None, False]) + self.authenticator.start_listener.assert_called_once_with(443, key) + + def test_perform_with_pending_tasks(self): + self.authenticator.tasks = {"foononce.acme.invalid": "cert_data"} + extra_challenge = challenge_util.DvsniChall("a", "b", "c", "d") + self.assertRaises( + ValueError, self.authenticator.perform, [extra_challenge]) + + def test_perform_without_challenge_list(self): + extra_challenge = challenge_util.DvsniChall("a", "b", "c", "d") + # This is wrong because a challenge must be specified. + self.assertRaises(ValueError, self.authenticator.perform, []) + # This is wrong because it must be a list, not a bare challenge. + self.assertRaises( + ValueError, self.authenticator.perform, extra_challenge) + # This is wrong because the list must contain at least one challenge. + self.assertRaises( + ValueError, self.authenticator.perform, range(20)) + + +class StartListenerTest(unittest.TestCase): + """Tests for start_listener() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + @mock.patch("letsencrypt.client.standalone_authenticator." + "Crypto.Random.atfork") + @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + def test_start_listener_fork_parent(self, mock_fork, mock_atfork): + self.authenticator.do_parent_process = mock.Mock() + self.authenticator.do_parent_process.return_value = True + mock_fork.return_value = 22222 + result = self.authenticator.start_listener(1717, "key") + # start_listener is expected to return the True or False return + # value from do_parent_process. + self.assertTrue(result) + self.assertEqual(self.authenticator.child_pid, 22222) + self.authenticator.do_parent_process.assert_called_once_with(1717) + mock_atfork.assert_called_once_with() + + @mock.patch("letsencrypt.client.standalone_authenticator." + "Crypto.Random.atfork") + @mock.patch("letsencrypt.client.standalone_authenticator.os.fork") + def test_start_listener_fork_child(self, mock_fork, mock_atfork): + self.authenticator.do_parent_process = mock.Mock() + self.authenticator.do_child_process = mock.Mock() + mock_fork.return_value = 0 + self.authenticator.start_listener(1717, "key") + self.assertEqual(self.authenticator.child_pid, os.getpid()) + self.authenticator.do_child_process.assert_called_once_with( + 1717, "key") + mock_atfork.assert_called_once_with() + +class DoParentProcessTest(unittest.TestCase): + """Tests for do_parent_process() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + + @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") + @mock.patch("letsencrypt.client.standalone_authenticator." + "zope.component.getUtility") + def test_do_parent_process_ok(self, mock_get_utility, mock_signal): + self.authenticator.subproc_state = "ready" + result = self.authenticator.do_parent_process(1717) + self.assertTrue(result) + self.assertEqual(mock_get_utility.call_count, 1) + self.assertEqual(mock_signal.call_count, 3) + + @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") + @mock.patch("letsencrypt.client.standalone_authenticator." + "zope.component.getUtility") + def test_do_parent_process_inuse(self, mock_get_utility, mock_signal): + self.authenticator.subproc_state = "inuse" + result = self.authenticator.do_parent_process(1717) + self.assertFalse(result) + self.assertEqual(mock_get_utility.call_count, 1) + self.assertEqual(mock_signal.call_count, 3) + + @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") + @mock.patch("letsencrypt.client.standalone_authenticator." + "zope.component.getUtility") + def test_do_parent_process_cantbind(self, mock_get_utility, mock_signal): + self.authenticator.subproc_state = "cantbind" + result = self.authenticator.do_parent_process(1717) + self.assertFalse(result) + self.assertEqual(mock_get_utility.call_count, 1) + self.assertEqual(mock_signal.call_count, 3) + + @mock.patch("letsencrypt.client.standalone_authenticator.signal.signal") + @mock.patch("letsencrypt.client.standalone_authenticator." + "zope.component.getUtility") + def test_do_parent_process_timeout(self, mock_get_utility, mock_signal): + # Normally times out in 5 seconds and returns False. We can + # now set delay_amount to a lower value so that it times out + # faster than it would under normal use. + result = self.authenticator.do_parent_process(1717, delay_amount=1) + self.assertFalse(result) + self.assertEqual(mock_get_utility.call_count, 1) + self.assertEqual(mock_signal.call_count, 3) + + +class DoChildProcessTest(unittest.TestCase): + """Tests for do_child_process() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + name, r_b64 = "example.com", jose.b64encode("x" * 32) + test_key = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') + nonce, key = "abcdef", le_util.Key("foo", test_key) + self.key = key + self.cert = challenge_util.dvsni_gen_cert(name, r_b64, nonce, key)[0] + private_key = OpenSSL.crypto.load_privatekey( + OpenSSL.crypto.FILETYPE_PEM, key.pem) + self.authenticator.private_key = private_key + self.authenticator.tasks = {"abcdef.acme.invalid": self.cert} + self.authenticator.parent_pid = 12345 + + @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + def test_do_child_process_cantbind1( + self, mock_exit, mock_kill, mock_socket): + mock_exit.side_effect = IndentationError("subprocess would exit here") + eaccess = socket.error(socket.errno.EACCES, "Permission denied") + sample_socket = mock.MagicMock() + sample_socket.bind.side_effect = eaccess + mock_socket.return_value = sample_socket + # Using the IndentationError as an error that cannot easily be + # generated at runtime, to indicate the behavior of sys.exit has + # taken effect without actually causing the test process to exit. + # (Just replacing it with a no-op causes logic errors because the + # do_child_process code assumes that calling sys.exit() will + # cause subsequent code not to be executed.) + self.assertRaises( + IndentationError, self.authenticator.do_child_process, 1717, + self.key) + mock_exit.assert_called_once_with(1) + mock_kill.assert_called_once_with(12345, signal.SIGUSR2) + + @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.sys.exit") + def test_do_child_process_cantbind2(self, mock_exit, mock_kill, + mock_socket): + mock_exit.side_effect = IndentationError("subprocess would exit here") + eaccess = socket.error(socket.errno.EADDRINUSE, "Port already in use") + sample_socket = mock.MagicMock() + sample_socket.bind.side_effect = eaccess + mock_socket.return_value = sample_socket + self.assertRaises( + IndentationError, self.authenticator.do_child_process, 1717, + self.key) + mock_exit.assert_called_once_with(1) + mock_kill.assert_called_once_with(12345, signal.SIGUSR1) + + @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + def test_do_child_process_cantbind3(self, mock_socket): + """Test case where attempt to bind socket results in an unhandled + socket error. (The expected behavior is arguably wrong because it + will crash the program; the reason for the expected behavior is + that we don't have a way to report arbitrary socket errors.)""" + eio = socket.error(socket.errno.EIO, "Imaginary unhandled error") + sample_socket = mock.MagicMock() + sample_socket.bind.side_effect = eio + mock_socket.return_value = sample_socket + self.assertRaises( + socket.error, self.authenticator.do_child_process, 1717, self.key) + + @mock.patch("letsencrypt.client.standalone_authenticator." + "OpenSSL.SSL.Connection") + @mock.patch("letsencrypt.client.standalone_authenticator.socket.socket") + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + def test_do_child_process_success(self, mock_kill, mock_socket, + mock_connection): + sample_socket = mock.MagicMock() + sample_socket.accept.side_effect = SocketAcceptOnlyNTimes(2) + mock_socket.return_value = sample_socket + mock_connection.return_value = mock.MagicMock() + self.assertRaises( + CallableExhausted, self.authenticator.do_child_process, 1717, + self.key) + mock_socket.assert_called_once_with() + sample_socket.bind.assert_called_once_with(("0.0.0.0", 1717)) + sample_socket.listen.assert_called_once_with(1) + self.assertEqual(sample_socket.accept.call_count, 3) + mock_kill.assert_called_once_with(12345, signal.SIGIO) + # TODO: We could have some tests about the fact that the listener + # asks OpenSSL to negotiate a TLS connection (and correctly + # sets the SNI callback function). + + +class CleanupTest(unittest.TestCase): + """Tests for cleanup() method.""" + def setUp(self): + from letsencrypt.client.standalone_authenticator import \ + StandaloneAuthenticator + self.authenticator = StandaloneAuthenticator() + self.authenticator.tasks = {"foononce.acme.invalid": "stuff"} + self.authenticator.child_pid = 12345 + + @mock.patch("letsencrypt.client.standalone_authenticator.os.kill") + @mock.patch("letsencrypt.client.standalone_authenticator.time.sleep") + def test_cleanup(self, mock_sleep, mock_kill): + mock_sleep.return_value = None + mock_kill.return_value = None + chall = challenge_util.DvsniChall( + "foo.example.com", "whee", "foononce", "key") + self.authenticator.cleanup([chall]) + mock_kill.assert_called_once_with(12345, signal.SIGINT) + mock_sleep.assert_called_once_with(1) + + def test_bad_cleanup(self): + chall = challenge_util.DvsniChall( + "bad.example.com", "whee", "badnonce", "key") + self.assertRaises(ValueError, self.authenticator.cleanup, [chall]) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index a2a5f3c62..d73f6b668 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -19,6 +19,7 @@ from letsencrypt.client import client from letsencrypt.client import display from letsencrypt.client import errors from letsencrypt.client import interfaces +from letsencrypt.client import le_util from letsencrypt.client import log def create_parser(): @@ -144,7 +145,7 @@ def main(): # pylint: disable=too-many-branches if args.privkey is None: privkey = client.init_key(args.rsa_key_size, config.key_dir) else: - privkey = client.Client.Key(args.privkey[0], args.privkey[1]) + privkey = le_util.Key(args.privkey[0], args.privkey[1]) acme = client.Client(config, privkey, auth, installer) diff --git a/requirements.txt b/requirements.txt index a95a0807f..a364c4e8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ python-augeas==0.5.0 requests==2.4.3 argparse==1.2.2 mock==1.0.1 +PyOpenSSL==0.13 diff --git a/setup.py b/setup.py index 93580f9af..f2550446f 100755 --- a/setup.py +++ b/setup.py @@ -27,6 +27,7 @@ install_requires = [ 'jsonschema', 'mock', 'pycrypto', + 'PyOpenSSL', 'python-augeas', 'python2-pythondialog', 'requests',