diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index c07f4054cb..350134350a 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -429,6 +429,15 @@ And here is an example of server-based authentication using SASL:: auth_password = SopelIsGreat! # your bot's password auth_target = PLAIN # default sasl mechanism +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 ----------- @@ -457,7 +466,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:: @@ -468,7 +477,7 @@ For example, this will use NickServ ``IDENTIFY`` command and SASL mechanism:: auth_password = SopelIsGreat! # your bot's password auth_target = NickServ # default value - # server-based auth + # SASL auth server_auth_method = sasl # select server-based auth server_auth_username = BotAccount # your bot's username server_auth_password = SopelIsGreat! # your bot's password diff --git a/sopel/config/core_section.py b/sopel/config/core_section.py index 14b75296e6..25f6eafe1b 100644 --- a/sopel/config/core_section.py +++ b/sopel/config/core_section.py @@ -176,7 +176,9 @@ class CoreSection(StaticSection): * ``PLAIN`` if using the ``sasl`` :attr:`auth_method` The nickname of the NickServ service, or the name of the desired SASL - mechanism, if :attr:`auth_method` is set to one of these methods. This value + mechanism, if :attr:`auth_method` is set to one of these 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`. @@ -263,6 +265,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. @@ -1068,7 +1082,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 5fe5d026ff..eeca142fdd 100644 --- a/sopel/coretasks.py +++ b/sopel/coretasks.py @@ -957,7 +957,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 [] @@ -1025,10 +1024,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 @@ -1036,11 +1048,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): @@ -1127,12 +1150,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 2e888baca9..b9a41a8368 100644 --- a/sopel/irc/__init__.py +++ b/sopel/irc/__init__.py @@ -153,6 +153,8 @@ def get_irc_backend(self): if has_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 12d0eb2b0a..1e8ab2555b 100644 --- a/sopel/irc/backends.py +++ b/sopel/irc/backends.py @@ -260,12 +260,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.""" @@ -278,10 +283,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,