From f8c3dd3b35018fa41e68331e71d14b9c9c2ed3f0 Mon Sep 17 00:00:00 2001 From: Dpeta <69427753+Dpeta@users.noreply.github.com> Date: Sat, 25 Feb 2023 04:46:59 +0100 Subject: [PATCH 1/4] Add basic pre-registration SASL authentication. Unfinished, currently breaks nickserv auto-identify when switching handles. --- irc.py | 55 +++++++++++++++++++++++++++++++++-------- pesterchum.py | 9 ++++--- scripts/irc_protocol.py | 24 +++++++++++++++++- 3 files changed, 73 insertions(+), 15 deletions(-) diff --git a/irc.py b/irc.py index a445d07..853d241 100644 --- a/irc.py +++ b/irc.py @@ -115,6 +115,8 @@ class PesterIRC(QtCore.QThread): "768": self._keynotset, "769": self._keynopermission, "770": self._metadatasubok, + "903": self._saslsuccess, # We did a SASL!! woo yeah!! (RPL_SASLSUCCESS) + "904": self._saslfail, # oh no,,, cringe,,, (ERR_SASLFAIL) "error": self._error, "join": self._join, "kick": self._kick, @@ -129,6 +131,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 +193,28 @@ 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!! + if self.mainwindow.userprofile.getAutoIdentify(): + self._send_irc.cap("REQ", "sasl") + self._send_irc.authenticate("PLAIN") + 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): @@ -827,13 +851,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 +884,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 +1031,23 @@ 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 _saslfail(self, *_msg): + """Handle 'RPL_SASLSUCCESS' reply from server, SASL authentication succeeded! woo yeah!!""" + self._send_irc.cap("END") + + def _saslsuccess(self, *_msg): + """Handle 'ERR_SASLFAIL' reply from server, SASL failed somehow.""" + self._send_irc.cap("END") + moodUpdated = QtCore.pyqtSignal(str, Mood) colorUpdated = QtCore.pyqtSignal(str, QtGui.QColor) messageReceived = QtCore.pyqtSignal(str, str) diff --git a/pesterchum.py b/pesterchum.py index 3a66518..9c9e725 100755 --- a/pesterchum.py +++ b/pesterchum.py @@ -2545,10 +2545,11 @@ class PesterWindow(MovingWindow): self.waitingMessages.answerMessage() def doAutoIdentify(self): - if self.userprofile.getAutoIdentify(): - self.sendMessage.emit( - "identify " + self.userprofile.getNickServPass(), "NickServ" - ) + pass + # if self.userprofile.getAutoIdentify(): + # self.sendMessage.emit( + # "identify " + self.userprofile.getNickServPass(), "NickServ" + # ) def doAutoJoins(self): if not self.autoJoinDone: diff --git a/scripts/irc_protocol.py b/scripts/irc_protocol.py index 4c054fd..ce7ed4b 100644 --- a/scripts/irc_protocol.py +++ b/scripts/irc_protocol.py @@ -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,27 @@ 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" + ) + # Woo yeah woo yeah + self._send("AUTHENTICATE", text=sasl_string_base64) + def parse_irc_line(line: str): """Retrieves tags, prefix, command, and arguments from an unparsed IRC line.""" From 2a65361c9219243932b39480e5f30b09032ef5b4 Mon Sep 17 00:00:00 2001 From: Dpeta <69427753+Dpeta@users.noreply.github.com> Date: Sat, 25 Feb 2023 18:18:19 +0100 Subject: [PATCH 2/4] Add post-connection SASL authentication (not used rn) --- irc.py | 14 +++++++++++--- pesterchum.py | 25 +++++++++++++++++-------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/irc.py b/irc.py index 853d241..05f4eb2 100644 --- a/irc.py +++ b/irc.py @@ -206,8 +206,9 @@ class PesterIRC(QtCore.QThread): # This should not be here. profile = self.mainwindow.profile() # Do SASL!! + self._send_irc.cap("REQ", "sasl") if self.mainwindow.userprofile.getAutoIdentify(): - self._send_irc.cap("REQ", "sasl") + # Send plain, send end later when 903 or 904 is received. self._send_irc.authenticate("PLAIN") else: # Without SASL, end caps here. @@ -539,6 +540,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("!")] @@ -1042,11 +1048,13 @@ class PesterIRC(QtCore.QThread): def _saslfail(self, *_msg): """Handle 'RPL_SASLSUCCESS' reply from server, SASL authentication succeeded! woo yeah!!""" - self._send_irc.cap("END") + if not self.registered_irc: + self._send_irc.cap("END") def _saslsuccess(self, *_msg): """Handle 'ERR_SASLFAIL' reply from server, SASL failed somehow.""" - self._send_irc.cap("END") + if not self.registered_irc: + self._send_irc.cap("END") moodUpdated = QtCore.pyqtSignal(str, Mood) colorUpdated = QtCore.pyqtSignal(str, QtGui.QColor) diff --git a/pesterchum.py b/pesterchum.py index 9c9e725..5c19b92 100755 --- a/pesterchum.py +++ b/pesterchum.py @@ -2545,11 +2545,18 @@ class PesterWindow(MovingWindow): self.waitingMessages.answerMessage() def doAutoIdentify(self): - pass - # if self.userprofile.getAutoIdentify(): - # self.sendMessage.emit( - # "identify " + self.userprofile.getNickServPass(), "NickServ" - # ) + """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( + f"identify {self.userprofile.getNickServPass()}", "NickServ" + ) def doAutoJoins(self): if not self.autoJoinDone: @@ -2563,7 +2570,6 @@ class PesterWindow(MovingWindow): self.loadingscreen.done(QtWidgets.QDialog.DialogCode.Accepted) self.loadingscreen = None - self.doAutoIdentify() self.doAutoJoins() # Start client --> server pings @@ -4245,6 +4251,7 @@ class PesterWindow(MovingWindow): pingServer = QtCore.pyqtSignal() setAway = QtCore.pyqtSignal(bool) killSomeQuirks = QtCore.pyqtSignal(str, str) + sendAuthenticate = QtCore.pyqtSignal(str) class PesterTray(QtWidgets.QSystemTrayIcon): @@ -4403,8 +4410,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), @@ -4429,7 +4437,8 @@ 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), + # Connect IRC signal to widget slot/function. (IRC --> Widget) (irc.connected, widget.connected), (irc.askToConnect, widget.connectAnyway), (irc.moodUpdated, widget.updateMoodSlot), From 5f817867ebf544a70d9afaa27bf5a4166399ff59 Mon Sep 17 00:00:00 2001 From: Dpeta <69427753+Dpeta@users.noreply.github.com> Date: Sat, 25 Feb 2023 21:22:36 +0100 Subject: [PATCH 3/4] Allow more replies to end SASL auth --- irc.py | 15 +++++++++++---- scripts/irc_protocol.py | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/irc.py b/irc.py index 05f4eb2..74b0be6 100644 --- a/irc.py +++ b/irc.py @@ -115,8 +115,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._saslfail, # oh no,,, cringe,,, (ERR_SASLFAIL) + "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, @@ -1046,13 +1048,18 @@ class PesterIRC(QtCore.QThread): password=self.mainwindow.userprofile.getNickServPass(), ) - def _saslfail(self, *_msg): - """Handle 'RPL_SASLSUCCESS' reply from server, SASL authentication succeeded! woo yeah!!""" + 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.""" if not self.registered_irc: + self._send_irc.authenticate( + "*" + ) # Abort SASL, optional since we send cap END anyway. self._send_irc.cap("END") def _saslsuccess(self, *_msg): - """Handle 'ERR_SASLFAIL' reply from server, SASL failed somehow.""" + """Handle 'RPL_SASLSUCCESS' reply from server, SASL authentication succeeded! woo yeah!!""" if not self.registered_irc: self._send_irc.cap("END") diff --git a/scripts/irc_protocol.py b/scripts/irc_protocol.py index ce7ed4b..61092ba 100644 --- a/scripts/irc_protocol.py +++ b/scripts/irc_protocol.py @@ -176,7 +176,8 @@ class SendIRC: sasl_string_base64 = base64.b64encode(sasl_string_bytes).decode( encoding="utf-8" ) - # Woo yeah woo yeah + # 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) From 82c7be4516f907ed5a83680ba23839dfb1218e54 Mon Sep 17 00:00:00 2001 From: Dpeta <69427753+Dpeta@users.noreply.github.com> Date: Tue, 28 Feb 2023 23:32:35 +0100 Subject: [PATCH 4/4] Timeout capability negotiation after 5 seconds when waiting on SASL. --- irc.py | 27 ++++++++++++++++++--------- pesterchum.py | 11 +++++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/irc.py b/irc.py index 74b0be6..5a2d0b3 100644 --- a/irc.py +++ b/irc.py @@ -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, @@ -212,6 +213,8 @@ class PesterIRC(QtCore.QThread): 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") @@ -316,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!!""" @@ -326,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 @@ -1051,17 +1063,13 @@ class PesterIRC(QtCore.QThread): 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.""" - if not self.registered_irc: - self._send_irc.authenticate( - "*" - ) # Abort SASL, optional since we send cap END anyway. - self._send_irc.cap("END") + 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!!""" - if not self.registered_irc: - self._send_irc.cap("END") + self.end_cap_negotiation() moodUpdated = QtCore.pyqtSignal(str, Mood) colorUpdated = QtCore.pyqtSignal(str, QtGui.QColor) @@ -1082,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() diff --git a/pesterchum.py b/pesterchum.py index 5c19b92..f7f1055 100755 --- a/pesterchum.py +++ b/pesterchum.py @@ -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, @@ -3811,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(":") @@ -4438,6 +4447,7 @@ class MainProgram(QtCore.QObject): (widget.disconnectIRC, irc.disconnect_irc), (widget.changeNick, irc.send_nick), (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), @@ -4458,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):