Merge pull request #124 from Dpeta/sasl

Use SASL when auto-identifying to NickServ on connect.
This commit is contained in:
Dpeta 2023-02-28 23:40:06 +01:00 committed by GitHub
commit f09c6eeafd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 119 additions and 16 deletions

81
irc.py
View file

@ -95,6 +95,7 @@ class PesterIRC(QtCore.QThread):
self.channel_list = []
self.channel_field = None
# Dict for connection server commands/replies to handling functions.
self.commands = {
"001": self._welcome,
"005": self._featurelist,
@ -115,6 +116,10 @@ class PesterIRC(QtCore.QThread):
"768": self._keynotset,
"769": self._keynopermission,
"770": self._metadatasubok,
"902": self._sasl_skill_issue, # ERR_NICKLOCKED, account is not available...
"903": self._saslsuccess, # We did a SASL!! woo yeah!! (RPL_SASLSUCCESS)
"904": self._sasl_skill_issue, # oh no,,, cringe,,, (ERR_SASLFAIL)
"905": self._sasl_skill_issue, # ERR_SASLTOOLONG, we don't split so end.
"error": self._error,
"join": self._join,
"kick": self._kick,
@ -129,6 +134,7 @@ class PesterIRC(QtCore.QThread):
"metadata": self._metadata, # Metadata specification
"tagmsg": self._tagmsg, # IRCv3 message tags extension
"cap": self._cap, # IRCv3 Client Capability Negotiation
"authenticate": self._authenticate, # IRCv3 SASL authentication
}
def run(self):
@ -190,7 +196,31 @@ class PesterIRC(QtCore.QThread):
if self.password:
self._send_irc.pass_(self.password)
self._send_irc.nick(self.mainwindow.profile().handle)
# Negotiate capabilities
self._send_irc.cap("REQ", "message-tags")
self._send_irc.cap(
"REQ",
"draft/metadata-notify-2", # <--- Not required for the unreal5 module.
)
self._send_irc.cap("REQ", "pesterchum-tag") # <--- Currently not using this
self._send_irc.cap("REQ", "twitch.tv/membership") # Twitch silly
# This should not be here.
profile = self.mainwindow.profile()
# Do SASL!!
self._send_irc.cap("REQ", "sasl")
if self.mainwindow.userprofile.getAutoIdentify():
# Send plain, send end later when 903 or 904 is received.
self._send_irc.authenticate("PLAIN")
# Always call CAP END after 5 seconds.
self.cap_negotation_started.emit()
else:
# Without SASL, end caps here.
self._send_irc.cap("END")
# Send NICK & USER :3
self._send_irc.nick(profile.handle)
self._send_irc.user("pcc31", "pcc31")
def _conn_generator(self):
@ -289,6 +319,15 @@ class PesterIRC(QtCore.QThread):
PchumLog.critical("set_connection_broken() got called, disconnecting.")
self.disconnectIRC()
def end_cap_negotiation(self):
"""Send CAP END to end capability negotation.
Called from SASL-related functions here,
but also from a timer on the main thread that always triggers after 5 seconds.
"""
if not self.registered_irc:
self._send_irc.cap("END")
@QtCore.pyqtSlot()
def update_irc(self):
"""Get a silly scrunkler from the generator!!"""
@ -299,7 +338,7 @@ class PesterIRC(QtCore.QThread):
return True
raise socket_exception
except StopIteration:
self._conn = self.conn_generator()
self._conn = self._conn_generator()
return True
else:
return res
@ -515,6 +554,11 @@ class PesterIRC(QtCore.QThread):
def send_nick(self, nick: str):
self._send_irc.nick(nick)
@QtCore.pyqtSlot(str)
def send_authenticate(self, msg):
"""Called from main thread via signal, send requirements."""
self._send_irc.authenticate(msg)
def _notice(self, nick, chan, msg):
"""Standard IRC 'NOTICE' message, primarily used for automated replies from services."""
handle = nick[0 : nick.find("!")]
@ -827,13 +871,6 @@ class PesterIRC(QtCore.QThread):
)
self.connected.emit() # Alert main thread that we've connected.
profile = self.mainwindow.profile()
# Negotiate capabilities
self._send_irc.cap("REQ", "message-tags")
self._send_irc.cap(
"REQ", "draft/metadata-notify-2"
) # <--- Not required in the unreal5 module implementation
self._send_irc.cap("REQ", "pesterchum-tag") # <--- Currently not using this
self._send_irc.cap("REQ", "twitch.tv/membership") # Twitch silly
# Get mood
mood = profile.mood.value_str()
# Moods via metadata
@ -867,8 +904,9 @@ class PesterIRC(QtCore.QThread):
See: https://ircv3.net/specs/extensions/capability-negotiation
"""
PchumLog.info("CAP %s %s %s %s", server, nick, subcommand, tag)
# if tag == "message-tags":
# if subcommand == "ACK":
if subcommand.casefold() == "nak" and tag.casefold() == "sasl":
# SASL isn't supported, end CAP negotation.
self._send_irc.cap("END")
def _umodeis(self, _server, _handle, modes):
"""Numeric reply 221 RPL_UMODEIS, shows us our user modes."""
@ -1013,6 +1051,26 @@ class PesterIRC(QtCore.QThread):
""" "METADATA DRAFT numeric reply 770 RPL_METADATASUBOK, we subbed to a key."""
PchumLog.info("_metadatasubok: %s", params)
def _authenticate(self, _, token):
"""Handle IRCv3 SASL authneticate command from server."""
if token == "+":
# Try to send password now
self._send_irc.authenticate(
nick=self.mainwindow.profile().handle,
password=self.mainwindow.userprofile.getNickServPass(),
)
def _sasl_skill_issue(self, *_msg):
"""Handles all responses from server that indicate SASL authentication failed.
Replies that indicate we can't authenticate include: 902, 904, 905.
Aborts SASL by sending CAP END, ending capability negotiation."""
self.end_cap_negotiation()
def _saslsuccess(self, *_msg):
"""Handle 'RPL_SASLSUCCESS' reply from server, SASL authentication succeeded! woo yeah!!"""
self.end_cap_negotiation()
moodUpdated = QtCore.pyqtSignal(str, Mood)
colorUpdated = QtCore.pyqtSignal(str, QtGui.QColor)
messageReceived = QtCore.pyqtSignal(str, str)
@ -1032,3 +1090,4 @@ class PesterIRC(QtCore.QThread):
userPresentUpdate = QtCore.pyqtSignal(str, str, str)
cannotSendToChan = QtCore.pyqtSignal(str, str)
signal_forbiddenchannel = QtCore.pyqtSignal(str, str)
cap_negotation_started = QtCore.pyqtSignal()

View file

@ -1633,6 +1633,10 @@ class PesterWindow(MovingWindow):
self.waitingMessages = waitingMessageHolder(self)
# Create timer for IRC cap negotiation timeout, started in capStarted().
self.cap_negotiation_timeout = QtCore.QTimer()
self.cap_negotiation_timeout.singleShot = True
self.idler = {
# autoidle
"auto": False,
@ -2545,9 +2549,17 @@ class PesterWindow(MovingWindow):
self.waitingMessages.answerMessage()
def doAutoIdentify(self):
"""Identify to NickServ after we've already connected and are switching handle.
It'd be better to do this with only the AUTHENTICATE command even after connecting,
but UnrealIRCd doens't seem to support it yet? https://bugs.unrealircd.org/view.php?id=6084
The protocol allows it though, so hopefully it'll be a thing in the future.
For now it's better to just msg too for backwards compatibility.
"""
if self.userprofile.getAutoIdentify():
# self.sendAuthenticate.emit("PLAIN")
self.sendMessage.emit(
"identify " + self.userprofile.getNickServPass(), "NickServ"
f"identify {self.userprofile.getNickServPass()}", "NickServ"
)
def doAutoJoins(self):
@ -2562,7 +2574,6 @@ class PesterWindow(MovingWindow):
self.loadingscreen.done(QtWidgets.QDialog.DialogCode.Accepted)
self.loadingscreen = None
self.doAutoIdentify()
self.doAutoJoins()
# Start client --> server pings
@ -3804,6 +3815,11 @@ class PesterWindow(MovingWindow):
self.parent.trayicon.hide()
self.app.quit()
@QtCore.pyqtSlot()
def capNegotationStarted(self):
"""IRC thread started capabilities negotiation, end it if it takes longer than 5 seconds."""
self.cap_negotiation_timeout.start(5000)
def updateServerJson(self):
PchumLog.info("'%s' chosen.", self.customServerPrompt_qline.text())
server_and_port = self.customServerPrompt_qline.text().split(":")
@ -4244,6 +4260,7 @@ class PesterWindow(MovingWindow):
pingServer = QtCore.pyqtSignal()
setAway = QtCore.pyqtSignal(bool)
killSomeQuirks = QtCore.pyqtSignal(str, str)
sendAuthenticate = QtCore.pyqtSignal(str)
class PesterTray(QtWidgets.QSystemTrayIcon):
@ -4402,8 +4419,9 @@ class MainProgram(QtCore.QObject):
self.widget.config.set("traymsg", False)
def ircQtConnections(self, irc, widget):
# IRC --> Main window
return (
# Connect widget signal to IRC slot/function. (IRC --> Widget)
# IRC runs on a different thread.
(widget.sendMessage, irc.send_message),
(widget.sendNotice, irc.send_notice),
(widget.sendCTCP, irc.send_ctcp),
@ -4428,7 +4446,9 @@ class MainProgram(QtCore.QObject):
(widget.killSomeQuirks, irc.kill_some_quirks),
(widget.disconnectIRC, irc.disconnect_irc),
(widget.changeNick, irc.send_nick),
# Main window --> IRC
(widget.sendAuthenticate, irc.send_authenticate),
(widget.cap_negotiation_timeout.timeout, irc.end_cap_negotiation),
# Connect IRC signal to widget slot/function. (IRC --> Widget)
(irc.connected, widget.connected),
(irc.askToConnect, widget.connectAnyway),
(irc.moodUpdated, widget.updateMoodSlot),
@ -4448,6 +4468,7 @@ class MainProgram(QtCore.QObject):
(irc.modesUpdated, widget.modesUpdated),
(irc.cannotSendToChan, widget.cannotSendToChan),
(irc.signal_forbiddenchannel, widget.forbiddenchannel),
(irc.cap_negotation_started, widget.capNegotationStarted),
)
def connectWidgets(self, irc, widget):

View file

@ -1,5 +1,6 @@
"""IRC-related functions and classes to be imported by irc.py"""
import logging
import base64
PchumLog = logging.getLogger("pchumLogger")
@ -41,7 +42,7 @@ class SendIRC:
try:
PchumLog.debug("Sending: %s", command)
self.socket.sendall(outgoing_bytes)
self.socket.send(outgoing_bytes)
except OSError:
PchumLog.exception("Error while sending: '%s'", command.strip())
self.socket.close()
@ -157,6 +158,28 @@ class SendIRC:
"""Send QUIT to terminate connection."""
self._send("QUIT", text=reason)
def authenticate(self, token=None, nick=None, password=None):
"""Authenticate command for SASL.
Send either a token like 'PLAIN' or authenticate with nick and password.
Reference: https://ircv3.net/irc/#account-authentication-and-registration
"""
if token:
self._send("AUTHENTICATE", text=token)
return
if nick and password:
# Authentication identity 'nick', authorization identity 'nick' and password 'password'.
sasl_string = f"{nick}\x00{nick}\x00{password}"
# Encode to use base64, then decode since 'text' only takes str.
sasl_string_bytes = sasl_string.encode(encoding="utf-8", errors="replace")
sasl_string_base64 = base64.b64encode(sasl_string_bytes).decode(
encoding="utf-8"
)
# SASL string needs to be under 400 bytes,
# splitting over multiple messages is in the protocol but not implemented here.
self._send("AUTHENTICATE", text=sasl_string_base64)
def parse_irc_line(line: str):
"""Retrieves tags, prefix, command, and arguments from an unparsed IRC line."""