diff --git a/certbot/plugins/common.py b/certbot/plugins/common.py index aa58e86cc..255563bb6 100644 --- a/certbot/plugins/common.py +++ b/certbot/plugins/common.py @@ -241,6 +241,10 @@ class TLSSNI01(object): return os.path.join(self.configurator.config.work_dir, achall.chall.encode("token") + '.pem') + def get_z_domain(self, achall): + """Returns z_domain (SNI) name for the challenge.""" + return achall.response(achall.account_key).z_domain.decode("utf-8") + def _setup_challenge_cert(self, achall, cert_key=None): """Generate and write out challenge certificate.""" diff --git a/certbot/plugins/common_test.py b/certbot/plugins/common_test.py index 3eedf92f7..411cbe651 100644 --- a/certbot/plugins/common_test.py +++ b/certbot/plugins/common_test.py @@ -221,6 +221,11 @@ class TLSSNI01Test(unittest.TestCase): mock_safe_open.return_value.write.assert_called_once_with( OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_PEM, key)) + def test_get_z_domain(self): + achall = self.achalls[0] + self.assertEqual(self.sni.get_z_domain(achall), + achall.response(achall.account_key).z_domain.decode("utf-8")) + class InstallSslOptionsConfTest(test_util.TempDirTestCase): """Tests for certbot.plugins.common.install_ssl_options_conf.""" diff --git a/certbot/plugins/manual.py b/certbot/plugins/manual.py index 093fec4be..07371ad34 100644 --- a/certbot/plugins/manual.py +++ b/certbot/plugins/manual.py @@ -9,9 +9,38 @@ from acme import challenges from certbot import interfaces from certbot import errors from certbot import hooks +from certbot import reverter from certbot.plugins import common +class ManualTlsSni01(common.TLSSNI01): + """TLS-SNI-01 authenticator for the Manual plugin + + :ivar configurator: Authenticator object + :type configurator: :class:`~certbot.plugins.manual.Authenticator` + + :ivar list achalls: Annotated + class:`~certbot.achallenges.KeyAuthorizationAnnotatedChallenge` + challenges + + :param list indices: Meant to hold indices of challenges in a + larger array. NginxTlsSni01 is capable of solving many challenges + at once which causes an indexing issue within NginxConfigurator + who must return all responses in order. Imagine NginxConfigurator + maintaining state about where all of the http-01 Challenges, + TLS-SNI-01 Challenges belong in the response array. This is an + optional utility. + + :param str challenge_conf: location of the challenge config file + """ + + def perform(self): + """Create the SSL certificates and private keys""" + + for achall in self.achalls: + self._setup_challenge_cert(achall) + + @zope.interface.implementer(interfaces.IAuthenticator) @zope.interface.provider(interfaces.IPluginFactory) class Authenticator(common.Plugin): @@ -28,14 +57,18 @@ class Authenticator(common.Plugin): long_description = ( 'Authenticate through manual configuration or custom shell scripts. ' 'When using shell scripts, an authenticator script must be provided. ' - 'The environment variables available to this script are ' - '$CERTBOT_DOMAIN which contains the domain being authenticated, ' - '$CERTBOT_VALIDATION which is the validation string, and ' - '$CERTBOT_TOKEN which is the filename of the resource requested when ' - 'performing an HTTP-01 challenge. An additional cleanup script can ' - 'also be provided and can use the additional variable ' - '$CERTBOT_AUTH_OUTPUT which contains the stdout output from the auth ' - 'script.') + 'The environment variables available to this script depend on the ' + 'type of challenge. $CERTBOT_DOMAIN will always contain the domain ' + 'being authenticated. For HTTP-01 and DNS-01, $CERTBOT_VALIDATION ' + 'is the validation string, and $CERTBOT_TOKEN is the filename of the ' + 'resource requested when performing an HTTP-01 challenge. When ' + 'performing a TLS-SNI-01 challenge, $CERTBOT_SNI_DOMAIN will contain ' + 'the SNI name for which the ACME server expects to be presented with ' + 'the self-signed certificate located at $CERTBOT_CERT_PATH. The ' + 'secret key needed to complete the TLS handshake is located at ' + '$CERTBOT_KEY_PATH. An additional cleanup script can also be ' + 'provided and can use the additional variable $CERTBOT_AUTH_OUTPUT ' + 'which contains the stdout output from the auth script.') _DNS_INSTRUCTIONS = """\ Please deploy a DNS TXT record under the name {domain} with the following value: @@ -51,11 +84,22 @@ Create a file containing just this data: And make it available on your web server at this URL: {uri} +""" + _TLSSNI_INSTRUCTIONS = """\ +Configure the service listening on port {port} to present the certificate +{cert} +using the secret key +{key} +when it receives a TLS ClientHello with the SNI extension set to +{sni_domain} """ def __init__(self, *args, **kwargs): super(Authenticator, self).__init__(*args, **kwargs) + self.reverter = reverter.Reverter(self.config) + self.reverter.recovery_routine() self.env = dict() + self.tls_sni_01 = None @classmethod def add_parser_arguments(cls, add): @@ -90,11 +134,10 @@ And make it available on your web server at this URL: def get_chall_pref(self, domain): # pylint: disable=missing-docstring,no-self-use,unused-argument - return [challenges.HTTP01, challenges.DNS01] + return [challenges.HTTP01, challenges.DNS01, challenges.TLSSNI01] def perform(self, achalls): # pylint: disable=missing-docstring self._verify_ip_logging_ok() - if self.conf('auth-hook'): perform_achall = self._perform_achall_with_script else: @@ -102,6 +145,12 @@ And make it available on your web server at this URL: responses = [] for achall in achalls: + if isinstance(achall.chall, challenges.TLSSNI01): + # Make a new ManualTlsSni01 instance for each challenge + # because the manual plugin deals with one challenge at a time. + self.tls_sni_01 = ManualTlsSni01(self) + self.tls_sni_01.add_chall(achall) + self.tls_sni_01.perform() perform_achall(achall) responses.append(achall.response(achall.account_key)) return responses @@ -127,6 +176,16 @@ And make it available on your web server at this URL: env['CERTBOT_TOKEN'] = achall.chall.encode('token') else: os.environ.pop('CERTBOT_TOKEN', None) + if isinstance(achall.chall, challenges.TLSSNI01): + env['CERTBOT_CERT_PATH'] = self.tls_sni_01.get_cert_path(achall) + env['CERTBOT_KEY_PATH'] = self.tls_sni_01.get_key_path(achall) + env['CERTBOT_SNI_DOMAIN'] = self.tls_sni_01.get_z_domain(achall) + os.environ.pop('CERTBOT_VALIDATION', None) + env.pop('CERTBOT_VALIDATION') + else: + os.environ.pop('CERTBOT_CERT_PATH', None) + os.environ.pop('CERTBOT_KEY_PATH', None) + os.environ.pop('CERTBOT_SNI_DOMAIN', None) os.environ.update(env) _, out = hooks.execute(self.conf('auth-hook')) env['CERTBOT_AUTH_OUTPUT'] = out.strip() @@ -139,11 +198,17 @@ And make it available on your web server at this URL: achall=achall, encoded_token=achall.chall.encode('token'), port=self.config.http01_port, uri=achall.chall.uri(achall.domain), validation=validation) - else: - assert isinstance(achall.chall, challenges.DNS01) + elif isinstance(achall.chall, challenges.DNS01): msg = self._DNS_INSTRUCTIONS.format( domain=achall.validation_domain_name(achall.domain), validation=validation) + else: + assert isinstance(achall.chall, challenges.TLSSNI01) + msg = self._TLSSNI_INSTRUCTIONS.format( + cert=self.tls_sni_01.get_cert_path(achall), + key=self.tls_sni_01.get_key_path(achall), + port=self.config.tls_sni_01_port, + sni_domain=self.tls_sni_01.get_z_domain(achall)) display = zope.component.getUtility(interfaces.IDisplay) display.notification(msg, wrap=False, force_interactive=True) @@ -155,3 +220,4 @@ And make it available on your web server at this URL: os.environ.pop('CERTBOT_TOKEN', None) os.environ.update(env) hooks.execute(self.conf('cleanup-hook')) + self.reverter.recovery_routine() diff --git a/certbot/plugins/manual_test.py b/certbot/plugins/manual_test.py index bd6816f02..ac528e81c 100644 --- a/certbot/plugins/manual_test.py +++ b/certbot/plugins/manual_test.py @@ -13,17 +13,31 @@ from certbot.tests import acme_util from certbot.tests import util as test_util -class AuthenticatorTest(unittest.TestCase): +class AuthenticatorTest(test_util.TempDirTestCase): """Tests for certbot.plugins.manual.Authenticator.""" def setUp(self): + super(AuthenticatorTest, self).setUp() self.http_achall = acme_util.HTTP01_A self.dns_achall = acme_util.DNS01_A - self.achalls = [self.http_achall, self.dns_achall] + self.tls_sni_achall = acme_util.TLSSNI01_A + self.achalls = [self.http_achall, self.dns_achall, self.tls_sni_achall] + for d in ["config_dir", "work_dir", "in_progress"]: + os.mkdir(os.path.join(self.tempdir, d)) + # "backup_dir" and "temp_checkpoint_dir" get created in + # certbot.util.make_or_verify_dir() during the Reverter + # initialization. self.config = mock.MagicMock( http01_port=0, manual_auth_hook=None, manual_cleanup_hook=None, manual_public_ip_logging_ok=False, noninteractive_mode=False, - validate_hooks=False) + validate_hooks=False, + config_dir=os.path.join(self.tempdir, "config_dir"), + work_dir=os.path.join(self.tempdir, "work_dir"), + backup_dir=os.path.join(self.tempdir, "backup_dir"), + temp_checkpoint_dir=os.path.join( + self.tempdir, "temp_checkpoint_dir"), + in_progress_dir=os.path.join(self.tempdir, "in_progess"), + tls_sni_01_port=5001) from certbot.plugins.manual import Authenticator self.auth = Authenticator(self.config, name='manual') @@ -42,7 +56,9 @@ class AuthenticatorTest(unittest.TestCase): def test_get_chall_pref(self): self.assertEqual(self.auth.get_chall_pref('example.org'), - [challenges.HTTP01, challenges.DNS01]) + [challenges.HTTP01, + challenges.DNS01, + challenges.TLSSNI01]) @test_util.patch_get_utility() def test_ip_logging_not_ok(self, mock_get_utility): @@ -58,13 +74,19 @@ class AuthenticatorTest(unittest.TestCase): def test_script_perform(self): self.config.manual_public_ip_logging_ok = True self.config.manual_auth_hook = ( - 'echo $CERTBOT_DOMAIN; echo ${CERTBOT_TOKEN:-notoken}; ' - 'echo $CERTBOT_VALIDATION;') - dns_expected = '{0}\n{1}\n{2}'.format( + 'echo ${CERTBOT_DOMAIN}; ' + 'echo ${CERTBOT_TOKEN:-notoken}; ' + 'echo ${CERTBOT_CERT_PATH:-nocert}; ' + 'echo ${CERTBOT_KEY_PATH:-nokey}; ' + 'echo ${CERTBOT_SNI_DOMAIN:-nosnidomain}; ' + 'echo ${CERTBOT_VALIDATION:-novalidation};') + dns_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}'.format( self.dns_achall.domain, 'notoken', + 'nocert', 'nokey', 'nosnidomain', self.dns_achall.validation(self.dns_achall.account_key)) - http_expected = '{0}\n{1}\n{2}'.format( + http_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}'.format( self.http_achall.domain, self.http_achall.chall.encode('token'), + 'nocert', 'nokey', 'nosnidomain', self.http_achall.validation(self.http_achall.account_key)) self.assertEqual( @@ -76,6 +98,17 @@ class AuthenticatorTest(unittest.TestCase): self.assertEqual( self.auth.env[self.http_achall.domain]['CERTBOT_AUTH_OUTPUT'], http_expected) + # tls_sni_01 challenge must be perform()ed above before we can + # get the cert_path and key_path. + tls_sni_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}'.format( + self.tls_sni_achall.domain, 'notoken', + self.auth.tls_sni_01.get_cert_path(self.tls_sni_achall), + self.auth.tls_sni_01.get_key_path(self.tls_sni_achall), + self.auth.tls_sni_01.get_z_domain(self.tls_sni_achall), + 'novalidation') + self.assertEqual( + self.auth.env[self.tls_sni_achall.domain]['CERTBOT_AUTH_OUTPUT'], + tls_sni_expected) @test_util.patch_get_utility() def test_manual_perform(self, mock_get_utility): @@ -85,7 +118,13 @@ class AuthenticatorTest(unittest.TestCase): [achall.response(achall.account_key) for achall in self.achalls]) for i, (args, kwargs) in enumerate(mock_get_utility().notification.call_args_list): achall = self.achalls[i] - self.assertTrue(achall.validation(achall.account_key) in args[0]) + if isinstance(achall.chall, challenges.TLSSNI01): + self.assertTrue( + self.auth.tls_sni_01.get_cert_path( + self.tls_sni_achall) in args[0]) + else: + self.assertTrue( + achall.validation(achall.account_key) in args[0]) self.assertFalse(kwargs['wrap']) def test_cleanup(self): @@ -98,16 +137,29 @@ class AuthenticatorTest(unittest.TestCase): self.auth.cleanup([achall]) self.assertEqual(os.environ['CERTBOT_AUTH_OUTPUT'], 'foo') self.assertEqual(os.environ['CERTBOT_DOMAIN'], achall.domain) - self.assertEqual( - os.environ['CERTBOT_VALIDATION'], - achall.validation(achall.account_key)) - + if (isinstance(achall.chall, challenges.HTTP01) or + isinstance(achall.chall, challenges.DNS01)): + self.assertEqual( + os.environ['CERTBOT_VALIDATION'], + achall.validation(achall.account_key)) if isinstance(achall.chall, challenges.HTTP01): self.assertEqual( os.environ['CERTBOT_TOKEN'], achall.chall.encode('token')) else: self.assertFalse('CERTBOT_TOKEN' in os.environ) + if isinstance(achall.chall, challenges.TLSSNI01): + self.assertEqual( + os.environ['CERTBOT_CERT_PATH'], + self.auth.tls_sni_01.get_cert_path(achall)) + self.assertEqual( + os.environ['CERTBOT_KEY_PATH'], + self.auth.tls_sni_01.get_key_path(achall)) + self.assertFalse( + os.path.exists(os.environ['CERTBOT_CERT_PATH'])) + self.assertFalse( + os.path.exists(os.environ['CERTBOT_KEY_PATH'])) + if __name__ == '__main__': diff --git a/docs/using.rst b/docs/using.rst index 4bbf81d50..3b76c3e06 100644 --- a/docs/using.rst +++ b/docs/using.rst @@ -54,9 +54,9 @@ standalone_ Y N | Uses a "standalone" webserver to obtain a certificate. | Requires port 80 or 443 to be available. This is useful on tls-sni-01_ (443) | systems with no webserver, or when direct integration with | the local webserver is not supported or not desired. -manual_ Y N | Helps you obtain a certificate by giving you instructions to http-01_ (80) or - | perform domain validation yourself. Additionally allows you dns-01_ (53) - | to specify scripts to automate the validation task in a +manual_ Y N | Helps you obtain a certificate by giving you instructions to http-01_ (80), + | perform domain validation yourself. Additionally allows you dns-01_ (53) or + | to specify scripts to automate the validation task in a tls-sni-01_ (443) | customized way. =========== ==== ==== =============================================================== ============================= @@ -175,13 +175,15 @@ the UI, you can use the plugin to obtain a certificate by specifying to copy and paste commands into another terminal session, which may be on a different computer. -The manual plugin can use either the ``http`` or the ``dns`` challenge. You -can use the ``--preferred-challenges`` option to choose the challenge of your -preference. +The manual plugin can use either the ``http``, ``dns`` or the +``tls-sni`` challenge. You can use the ``--preferred-challenges`` option +to choose the challenge of your preference. + The ``http`` challenge will ask you to place a file with a specific name and specific content in the ``/.well-known/acme-challenge/`` directory directly in the top-level directory (“web root”) containing the files served by your webserver. In essence it's the same as the webroot_ plugin, but not automated. + When using the ``dns`` challenge, ``certbot`` will ask you to place a TXT DNS record with specific contents under the domain name consisting of the hostname for which you want a certificate issued, prepended by ``_acme-challenge``. @@ -192,10 +194,16 @@ For example, for the domain ``example.com``, a zone file entry would look like: _acme-challenge.example.com. 300 IN TXT "gfj9Xq...Rg85nM" -Additionally you can specify scripts to prepare for validation and perform the -authentication procedure and/or clean up after it by using the -``--manual-auth-hook`` and ``--manual-cleanup-hook`` flags. This is described in -more depth in the hooks_ section. +When using the ``tls-sni`` challenge, ``certbot`` will prepare a self-signed +SSL certificate for you with the challenge validation appropriately +encoded into a subjectAlternatNames entry. You will need to configure +your SSL server to present this challenge SSL certificate to the ACME +server using SNI. + +Additionally you can specify scripts to prepare for validation and +perform the authentication procedure and/or clean up after it by using +the ``--manual-auth-hook`` and ``--manual-cleanup-hook`` flags. This is +described in more depth in the hooks_ section. .. _third-party-plugins: @@ -606,12 +614,15 @@ and ``--manual-cleanup-hook`` respectively and can be used as follows: certbot certonly --manual --manual-auth-hook /path/to/http/authenticator.sh --manual-cleanup-hook /path/to/http/cleanup.sh -d secure.example.com This will run the ``authenticator.sh`` script, attempt the validation, and then run -the ``cleanup.sh`` script. Additionally certbot will pass three environment +the ``cleanup.sh`` script. Additionally certbot will pass relevant environment variables to these scripts: - ``CERTBOT_DOMAIN``: The domain being authenticated -- ``CERTBOT_VALIDATION``: The validation string +- ``CERTBOT_VALIDATION``: The validation string (HTTP-01 and DNS-01 only) - ``CERTBOT_TOKEN``: Resource name part of the HTTP-01 challenge (HTTP-01 only) +- ``CERTBOT_CERT_PATH``: The challenge SSL certificate (TLS-SNI-01 only) +- ``CERTBOT_KEY_PATH``: The private key associated with the aforementioned SSL certificate (TLS-SNI-01 only) +- ``CERTBOT_SNI_DOMAIN``: The SNI name for which the ACME server expects to be presented the self-signed certificate located at ``$CERTBOT_CERT_PATH`` (TLS-SNI-01 only) Additionally for cleanup: