tls-sni-01 with the manual plugin (#4636)

* Add TLS-SNI-01 support to Manual plugin

* Add environment variable CERTBOT_SNI_DOMAIN for manual-auth-hook

* Make AuthenticatorTest inherit from TempDirTestCase

* Add test_get_z_domain()

* Document CERTBOT_SNI_DOMAIN in docs/using.rst
This commit is contained in:
Alexandre de Verteuil 2017-06-19 12:39:14 -04:00 committed by Brad Warren
parent 811d436d5a
commit ed717d6bc4
5 changed files with 175 additions and 37 deletions

View file

@ -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."""

View file

@ -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."""

View file

@ -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()

View file

@ -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__':

View file

@ -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: