diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 5ce1992dfe..a4780f146b 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -459,6 +459,15 @@ Example of authentication to a ZNC bouncer:: Don't forget to configure your ZNC to log in to the real network! +Finally, here is how to enable CertFP once you have a certificate that meets +your IRC network's requirements:: + + [core] + client_cert_file = /path/to/cert.pem # your bot's client certificate + # some networks require SASL EXTERNAL for CertFP to work + auth_method = sasl # if required + auth_target = EXTERNAL # if required + Multi-stage ----------- @@ -487,7 +496,7 @@ When :attr:`~CoreSection.server_auth_method` is defined the settings used are: * :attr:`~CoreSection.server_auth_username`: account's username * :attr:`~CoreSection.server_auth_password`: account's password * :attr:`~CoreSection.server_auth_sasl_mech`: the SASL mechanism to use - (defaults to ``PLAIN``) + (defaults to ``PLAIN``; ``EXTERNAL`` is also available) For example, this will use NickServ ``IDENTIFY`` command and SASL mechanism:: diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py index 020851c99a..10062717dd 100644 --- a/sopel/config/core_section.py +++ b/sopel/config/core_section.py @@ -176,7 +176,9 @@ class CoreSection(StaticSection): The nickname of the NickServ or UserServ service, or the name of the desired SASL mechanism, if :attr:`auth_method` is set to one of these - methods. This value is otherwise ignored. + methods. For SASL, the ``EXTERNAL`` option is available in case the IRC + network requires it (e.g. for CertFP using :attr:`client_cert_file`). This + value is otherwise ignored. See :ref:`Authentication`. """ @@ -265,6 +267,18 @@ class CoreSection(StaticSection): """ + client_cert_file = FilenameAttribute('client_cert_file') + """Filesystem path to a certificate file for CertFP. + + This is expected to be a ``.pem`` file containing both the certificate and + private key. Most networks that support CertFP will give instructions for + generating this, typically using OpenSSL. + + Some networks may refer to this authentication method as SASL EXTERNAL. + + .. versionadded:: 8.0 + """ + commands_on_connect = ListAttribute('commands_on_connect') """A list of commands to send upon successful connection to the IRC server. @@ -1070,7 +1084,12 @@ def homedir(self): :default: ``PLAIN`` + ``EXTERNAL`` is also supported, e.g. for using :attr:`client_cert_file` to + authenticate via CertFP. + .. versionadded:: 7.0 + .. versionchanged:: 8.0 + Added support for SASL EXTERNAL mechanism. """ server_auth_username = ValidatedAttribute('server_auth_username') diff --git a/sopel/coretasks.py b/sopel/coretasks.py index 2dd8fa941d..45937db403 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -996,7 +996,6 @@ def receive_cap_ack_sasl(bot): if not password: return - mech = mech or 'PLAIN' available_mechs = bot.server_capabilities.get('sasl', '') available_mechs = available_mechs.split(',') if available_mechs else [] @@ -1064,10 +1063,23 @@ def auth_proceed(bot, trigger): be ignored. If none is set, then this function does nothing. """ - if trigger.args[0] != '+': - # How did we get here? I am not good with computer. + if bot.config.core.auth_method == 'sasl': + mech = bot.config.core.auth_target or 'PLAIN' + elif bot.config.core.server_auth_method == 'sasl': + mech = bot.config.core.server_auth_sasl_mech or 'PLAIN' + else: + return + + if mech == 'EXTERNAL': + if trigger.args[0] != '+': + # not an expected response from the server; abort SASL + token = '*' + else: + token = '+' + + bot.write(('AUTHENTICATE', token)) return - # Is this right? + if bot.config.core.auth_method == 'sasl': sasl_username = bot.config.core.auth_username sasl_password = bot.config.core.auth_password @@ -1075,11 +1087,22 @@ def auth_proceed(bot, trigger): sasl_username = bot.config.core.server_auth_username sasl_password = bot.config.core.server_auth_password else: + # How did we get here? I am not good with computer return + sasl_username = sasl_username or bot.nick - sasl_token = _make_sasl_plain_token(sasl_username, sasl_password) - LOGGER.info("Sending SASL Auth token.") - send_authenticate(bot, sasl_token) + + if mech == 'PLAIN': + if trigger.args[0] != '+': + # not an expected response from the server; abort SASL + token = '*' + else: + sasl_token = _make_sasl_plain_token(sasl_username, sasl_password) + LOGGER.info("Sending SASL Auth token.") + send_authenticate(bot, sasl_token) + return + + # TODO: Implement SCRAM challenges def _make_sasl_plain_token(account, password): @@ -1166,12 +1189,16 @@ def sasl_mechs(bot, trigger): def _get_sasl_pass_and_mech(bot): password = None mech = None + if bot.config.core.auth_method == 'sasl': password = bot.config.core.auth_password mech = bot.config.core.auth_target elif bot.config.core.server_auth_method == 'sasl': password = bot.config.core.server_auth_password mech = bot.config.core.server_auth_sasl_mech + + mech = 'PLAIN' if mech is None else mech.upper() + return password, mech diff --git a/sopel/irc/__init__.py b/sopel/irc/__init__.py index ae265f7073..4a5f8f27b3 100644 --- a/sopel/irc/__init__.py +++ b/sopel/irc/__init__.py @@ -135,6 +135,8 @@ def get_irc_backend(self): if self.settings.core.use_ssl: backend_class = SSLAsynchatBackend backend_kwargs.update({ + 'certfile': self.settings.core.client_cert_file, + 'keyfile': self.settings.core.client_cert_file, 'verify_ssl': self.settings.core.verify_ssl, 'ca_certs': self.settings.core.ca_certs, }) diff --git a/sopel/irc/backends.py b/sopel/irc/backends.py index 9203e08899..56ac52d107 100644 --- a/sopel/irc/backends.py +++ b/sopel/irc/backends.py @@ -243,12 +243,17 @@ class SSLAsynchatBackend(AsynchatBackend): (default ``True``, for good reason) :param str ca_certs: filesystem path to a CA Certs file containing trusted root certificates + :param str certfile: filesystem path to a certificate for SSL/TLS client + authentication (CertFP) + :param str keyfile: filesystem path to the private key for ``certfile`` """ - def __init__(self, bot, verify_ssl=True, ca_certs=None, **kwargs): + def __init__(self, bot, verify_ssl=True, ca_certs=None, certfile=None, keyfile=None, **kwargs): AsynchatBackend.__init__(self, bot, **kwargs) self.verify_ssl = verify_ssl self.ssl = None self.ca_certs = ca_certs + self.certfile = certfile + self.keyfile = keyfile def handle_connect(self): """Handle potential TLS connection.""" @@ -261,10 +266,14 @@ def handle_connect(self): # version(s) it supports. if not self.verify_ssl: self.ssl = ssl.wrap_socket(self.socket, # lgtm [py/insecure-default-protocol] + certfile=self.certfile, + keyfile=self.keyfile, do_handshake_on_connect=True, suppress_ragged_eofs=True) else: self.ssl = ssl.wrap_socket(self.socket, # lgtm [py/insecure-default-protocol] + certfile=self.certfile, + keyfile=self.keyfile, do_handshake_on_connect=True, suppress_ragged_eofs=True, cert_reqs=ssl.CERT_REQUIRED,