home · contact · privacy
Add very rudimentary SASL PLAIN authentication mechanism.
authorChristian Heller <c.heller@plomlompom.de>
Mon, 4 Aug 2025 19:48:38 +0000 (21:48 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Mon, 4 Aug 2025 19:48:38 +0000 (21:48 +0200)
ircplom/client.py
ircplom/client_tui.py

index 9c679c6ef0d4e32209bdd20f4e6a1f7df70be1dc..e9f367a71ba8f0f945a6030ee8195fd9d8264627 100644 (file)
@@ -1,6 +1,7 @@
 'High-level IRC protocol / server connection management.'
 # built-ins
 from abc import ABC, abstractmethod
+from base64 import b64encode
 from dataclasses import dataclass
 from getpass import getuser
 from threading import Thread
@@ -90,7 +91,7 @@ class _CapsManager:
             self.clear()
             self.challenge('LS', '302')
             return []
-        if self._challenge_met('END'):
+        if self._challenged('END'):
             return [f'ignoring post-END CAP message not NEW, DEL: {params}']
         match params[0]:
             case 'LS' | 'LIST':
@@ -101,7 +102,6 @@ class _CapsManager:
                     self._dict[cap_name].enabled = params[0] == 'ACK'
         if self._challenge_met('LIST'):
             self.challenge('END')
-            self._challenge_set('END', done=True)
             return (['server capabilities (enabled: "+"):']
                     + [cap.str_for_log(cap_name)
                        for cap_name, cap in self._dict.items()])
@@ -121,6 +121,13 @@ class _CapsManager:
         self._send(IrcMessage(verb='CAP', params=params))
         self._challenge_set(challenge_key)
 
+    @property
+    def could_sasl_plain(self) -> bool:
+        'Whether opportunity for some SASL AUTHENTICATE PLAIN attempt.'
+        return (self._challenged('END')
+                and 'sasl' in self._dict
+                and 'PLAIN' in self._dict['sasl'].data.split(','))
+
     def _challenge_met(self, step: str) -> bool:
         return self._challenges.get(step, False)
 
@@ -158,6 +165,7 @@ class IrcConnSetup:
     hostname: str
     nickname: str
     realname: str
+    password: str
 
 
 class Client(ABC, ClientQueueMixin):
@@ -260,6 +268,19 @@ class Client(ABC, ClientQueueMixin):
             case 'CAP':
                 for to_log in self._caps.process_msg(msg.params[1:]):
                     self.log.add(to_log)
+                if self._caps.could_sasl_plain and self.conn_setup.password:
+                    # NB: spec recommends to AUTHENTICATE during, rather than
+                    # after, CAPS negotation; opting for simplicity now instead
+                    self.send(IrcMessage('AUTHENTICATE', ('PLAIN',)))
+            case 'AUTHENTICATE':
+                if msg.params == ('+',):
+                    auth = b64encode((self.conn_setup.nickname + '\0' +
+                                      self.conn_setup.nickname + '\0' +
+                                      self.conn_setup.password
+                                      ).encode('utf-8')).decode('utf-8')
+                    self.send(IrcMessage('AUTHENTICATE', (auth,)))
+            case '904':
+                self.log.alert('SASL authentication failed')
 
 
 @dataclass
index ced61290cf15fb781196995a517eaa7c5ed46e57..ab2450887b72e50d856333b9b08e58aa355988e7 100644 (file)
@@ -99,14 +99,16 @@ class ClientTui(BaseTui):
             client_wins[0].prompt.prefix_copy_to(win.prompt)
         return win
 
-    def cmd__connect(self, hostname: str, nickname: str, realname: str
+    def cmd__connect(self, hostname: str, nickname_and_pw: str, realname: str
                      ) -> None:
         'Create Client and pass it via NewClientEvent.'
+        split = nickname_and_pw.split(':', maxsplit=1)
+        nickname = split[0]
+        pw = split[1] if len(split) > 1 else ''
         self._put(NewClientEvent(
             _ClientKnowingTui(
                 q_out=self.q_out,
-                conn_setup=IrcConnSetup(hostname=hostname, nickname=nickname,
-                                        realname=realname))))
+                conn_setup=IrcConnSetup(hostname, nickname, realname, pw))))
 
 
 @dataclass