Skip to content

Commit

Permalink
Merge pull request #2100 from sopel-irc/certfp-support
Browse files Browse the repository at this point in the history
coretasks, irc: implement CertFP / SASL EXTERNAL authentication
  • Loading branch information
dgw committed Jul 1, 2021
2 parents b23f656 + 2bcd1ed commit 9a7f9b9
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 10 deletions.
11 changes: 10 additions & 1 deletion docs/source/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----------
Expand Down Expand Up @@ -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::

Expand Down
21 changes: 20 additions & 1 deletion sopel/config/core_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
"""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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')
Expand Down
41 changes: 34 additions & 7 deletions sopel/coretasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []

Expand Down Expand Up @@ -1064,22 +1063,46 @@ 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
elif bot.config.core.server_auth_method == 'sasl':
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):
Expand Down Expand Up @@ -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


Expand Down
2 changes: 2 additions & 0 deletions sopel/irc/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
11 changes: 10 additions & 1 deletion sopel/irc/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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,
Expand Down

0 comments on commit 9a7f9b9

Please sign in to comment.