diff --git a/src/conf.py b/src/conf.py index 8892994e2..c9f00645e 100644 --- a/src/conf.py +++ b/src/conf.py @@ -300,6 +300,12 @@ class SpaceSeparatedSetOfChannels(registry.SpaceSeparatedListOf): # Let's be explicit about it return None +class ValidSaslMechanism(registry.OnlySomeStrings): + validStrings = ('ecdsa-nist256p-challenge', 'external', 'plain') + +class SpaceSeparatedListOfSaslMechanisms(registry.SpaceSeparatedListOf): + Value = ValidSaslMechanism + def registerNetwork(name, password='', ssl=False, sasl_username='', sasl_password=''): network = registerGroup(supybot.networks, name) @@ -348,6 +354,9 @@ def registerNetwork(name, password='', ssl=False, sasl_username='', _("""Determines what SASL ECDSA key (if any) will be used on %s. The public key must be registered with NickServ for SASL ECDSA-NIST256P-CHALLENGE to work.""") % name, private=False)) + registerGlobalValue(sasl, 'mechanisms', SpaceSeparatedListOfSaslMechanisms( + ['ecdsa-nist256p-challenge', 'external', 'plain'], _("""Determines + what SASL mechanisms will be tried and in which order."""))) registerGlobalValue(network, 'socksproxy', registry.String('', _("""If not empty, determines the hostname of the socks proxy that will be used to connect to this network."""))) diff --git a/src/irclib.py b/src/irclib.py index aa8f71aae..eed5931d8 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -982,6 +982,22 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self.lastping = time.time() self.outstandingPing = False + network_config = conf.supybot.networks.get(self.network) + self.sasl_next_mechanisms = [] + self.sasl_current_mechanism = None + + for mechanism in network_config.sasl.mechanisms(): + if mechanism == 'ecdsa-nist256p-challenge' and \ + ecdsa and self.sasl_username and self.sasl_ecdsa_key: + self.sasl_next_mechanisms.append(mechanism) + elif mechanism == 'external' and ( + network_config.certfile() or + conf.supybot.protocols.irc.certfile()): + self.sasl_next_mechanisms.append(mechanism) + elif mechanism == 'plain' and \ + self.sasl_username and self.sasl_password: + self.sasl_next_mechanisms.append(mechanism) + REQUEST_CAPABILITIES = set(['account-notify', 'extended-join', 'multi-prefix', 'metadata-notify', 'account-tag', @@ -1016,19 +1032,23 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self.sendMsg(ircmsgs.user(self.ident, self.user)) - self.sasl = None - - if ecdsa and self.sasl_username and self.sasl_ecdsa_key: - self.sasl = 'ecdsa-nist256p-challenge' - elif (conf.supybot.networks.get(self.network).certfile() or - conf.supybot.protocols.irc.certfile()): - self.sasl = 'external' - elif self.sasl_username and self.sasl_password: - self.sasl = 'plain' - - if self.sasl: + if self.sasl_next_mechanisms: self.REQUEST_CAPABILITIES.add('sasl') + def sendSaslString(self, string): + for chunk in ircutils.authenticate_generator(string): + self.sendMsg(ircmsgs.IrcMsg(command='AUTHENTICATE', + args=(chunk,))) + + def tryNextSaslMechanism(self): + if self.sasl_next_mechanisms: + self.sasl_current_mechanism = self.sasl_next_mechanisms.pop(0) + self.sendMsg(ircmsgs.IrcMsg(command='AUTHENTICATE', + args=(self.sasl_current_mechanism.upper(),))) + else: + self.sasl_current_mechanism = None + self.sendMsg(ircmsgs.IrcMsg(command='CAP', args=('END',))) + def doAuthenticate(self, msg): if not self.authenticate_decoder: self.authenticate_decoder = ircutils.AuthenticateDecoder() @@ -1037,34 +1057,57 @@ class Irc(IrcCommandDispatcher, log.Firewalled): return # Waiting for other messages string = self.authenticate_decoder.get() self.authenticate_decoder = None - if string == b'': - log.info('%s: Authenticating using SASL.', self.network) - if self.sasl == 'external': - authstring = b'' - elif self.sasl == 'ecdsa-nist256p-challenge': - authstring = self.sasl_username.encode('utf-8') - elif self.sasl == 'plain': - authstring = b'\0'.join([ - self.sasl_username.encode('utf-8'), - self.sasl_username.encode('utf-8'), - self.sasl_password.encode('utf-8'), - ]) - - for chunk in ircutils.authenticate_generator(authstring): - self.sendMsg(ircmsgs.IrcMsg(command='AUTHENTICATE', - args=(chunk,))) - elif (string != b'' and self.sasl == 'ecdsa-nist256p-challenge'): + mechanism = self.sasl_current_mechanism + if mechanism == 'ecdsa-nist256p-challenge': + if string == b'': + self.sendSaslString(self.sasl_username.encode('utf-8')) + return try: with open(self.sasl_ecdsa_key) as fd: private_key = SigningKey.from_pem(fd.read()) authstring = private_key.sign(base64.b64decode(msg.args[0].encode())) - chunks = ircutils.authenticate_generator(authstring) + self.sendSaslString(authstring) except (BadDigestError, OSError, ValueError): - chunks = ['*'] - for chunk in chunks: self.sendMsg(ircmsgs.IrcMsg(command='AUTHENTICATE', - args=(chunk,))) + args=('*',))) + self.tryNextSaslMechanism() + elif mechanism == 'external': + self.sendSaslString(b'') + elif mechanism == 'plain': + authstring = b'\0'.join([ + self.sasl_username.encode('utf-8'), + self.sasl_username.encode('utf-8'), + self.sasl_password.encode('utf-8'), + ]) + self.sendSaslString(authstring) + + def do903(self, msg): + log.info('%s: SASL authentication successful', self.network) + self.queueMsg(ircmsgs.IrcMsg(command='CAP', args=('END',))) + + def do904(self, msg): + log.warning('%s: SASL authentication failed', self.network) + self.tryNextSaslMechanism() + + def do905(self, msg): + log.warning('%s: SASL authentication failed because the username or ' + 'password is too long.', self.network) + self.tryNextSaslMechanism() + + def do906(self, msg): + log.warning('%s: SASL authentication aborted', self.network) + self.tryNextSaslMechanism() + + def do907(self, msg): + log.warning('%s: Attempted SASL authentication when we were already ' + 'authenticated.', self.network) + self.tryNextSaslMechanism() + + def do908(self, msg): + log.info('%s: Supported SASL mechanisms: %s', + self.network, msg.args[1]) + # TODO: filter self.sasl_next_mechanisms def doCap(self, msg): subcommand = msg.args[1] @@ -1086,8 +1129,8 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self.network, caps) self.state.capabilities_ack.update(caps) - if 'sasl' in caps and self.sasl: - self.sendMsg(ircmsgs.IrcMsg(command='AUTHENTICATE', args=(self.sasl.upper(),))) + if 'sasl' in caps: + self.tryNextSaslMechanism() else: self.sendMsg(ircmsgs.IrcMsg(command='CAP', args=('END',))) def doCapNak(self, msg): @@ -1110,6 +1153,7 @@ class Irc(IrcCommandDispatcher, log.Firewalled): else: self.state.capabilities_ls[item] = None def doCapLs(self, msg): + # TODO: filter self.sasl_next_mechanisms if len(msg.args) == 4: # Multi-line LS if msg.args[2] != '*': @@ -1184,42 +1228,6 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self.queueMsg(ircmsgs.monitor('-', should_be_unmonitored)) return should_be_unmonitored - def do903(self, msg): - log.info('%s: SASL authentication successful', self.network) - self.queueMsg(ircmsgs.IrcMsg(command='CAP', args=('END',))) - - def do904(self, msg): - if (self.sasl != 'plain' and self.sasl_username and - self.sasl_password): - log.info('%s: SASL %s failed, trying PLAIN.', self.network, - self.sasl.upper()) - - self.sasl = 'plain' - - self.queueMsg(ircmsgs.IrcMsg( - command='AUTHENTICATE', args=(self.sasl.upper(),))) - else: - log.warning('%s: SASL authentication failed', self.network) - self.queueMsg(ircmsgs.IrcMsg(command='CAP', args=('END',))) - - def do905(self, msg): - log.warning('%s: SASL authentication failed because the username or ' - 'password is too long.', self.network) - self.queueMsg(ircmsgs.IrcMsg(command='CAP', args=('END',))) - - def do906(self, msg): - log.warning('%s: SASL authentication aborted', self.network) - self.queueMsg(ircmsgs.IrcMsg(command='CAP', args=('END',))) - - def do907(self, msg): - log.warning('%s: Attempted SASL authentication when we were already ' - 'authenticated.', self.network) - self.queueMsg(ircmsgs.IrcMsg(command='CAP', args=('END',))) - - def do908(self, msg): - log.info('%s: Supported SASL mechanisms: %s', - self.network, msg.args[1]) - def _getNextNick(self): if self.alternateNicks: nick = self.alternateNicks.pop(0) diff --git a/test/test_irclib.py b/test/test_irclib.py index f2db354ea..d832ac11f 100644 --- a/test/test_irclib.py +++ b/test/test_irclib.py @@ -527,6 +527,102 @@ class IrcTestCase(SupyTestCase): self.irc.removeCallback(c.name()) self.assertEqual(c.batch, irclib.Batch('netjoin', (), [m1, m2])) +class SaslTestCase(SupyTestCase): + def setUp(self): + pass + + def startCapNegociation(self): + m = self.irc.takeMsg() + self.failUnless(m.command == 'CAP', 'Expected CAP, got %r.' % m) + self.failUnless(m.args == ('LS', '302'), 'Expected CAP LS 302, got %r.' % m) + + m = self.irc.takeMsg() + self.failUnless(m.command == 'NICK', 'Expected NICK, got %r.' % m) + + m = self.irc.takeMsg() + self.failUnless(m.command == 'USER', 'Expected USER, got %r.' % m) + # TODO + self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', + args=('*', 'LS', 'sasl'))) + + m = self.irc.takeMsg() + self.failUnless(m.command == 'CAP', 'Expected CAP, got %r.' % m) + self.assertEqual(m.args[0], 'REQ', m) + self.assertEqual(m.args[1], 'sasl') + + self.irc.feedMsg(ircmsgs.IrcMsg(command='CAP', + args=('*', 'ACK', 'sasl'))) + + def endCapNegociation(self): + m = self.irc.takeMsg() + self.failUnless(m.command == 'CAP', 'Expected CAP, got %r.' % m) + self.assertEqual(m.args, ('END',), m) + + def testPlain(self): + try: + conf.supybot.networks.test.sasl.username.setValue('jilles') + conf.supybot.networks.test.sasl.password.setValue('sesame') + self.irc = irclib.Irc('test') + finally: + conf.supybot.networks.test.sasl.username.setValue('') + conf.supybot.networks.test.sasl.password.setValue('') + self.assertEqual(self.irc.sasl_current_mechanism, None) + self.assertEqual(self.irc.sasl_next_mechanisms, ['plain']) + + self.startCapNegociation() + + m = self.irc.takeMsg() + self.assertEqual(m, ircmsgs.IrcMsg(command='AUTHENTICATE', + args=('PLAIN',))) + + self.irc.feedMsg(ircmsgs.IrcMsg(command='AUTHENTICATE', args=('+',))) + + m = self.irc.takeMsg() + self.assertEqual(m, ircmsgs.IrcMsg(command='AUTHENTICATE', + args=('amlsbGVzAGppbGxlcwBzZXNhbWU=',))) + + self.irc.feedMsg(ircmsgs.IrcMsg(command='900', args=('jilles',))) + self.irc.feedMsg(ircmsgs.IrcMsg(command='903', args=('jilles',))) + + self.endCapNegociation() + + def testEcdsaFallbackToPlain(self): + try: + conf.supybot.networks.test.sasl.username.setValue('jilles') + conf.supybot.networks.test.sasl.password.setValue('sesame') + conf.supybot.networks.test.sasl.ecdsa_key.setValue('foo') + self.irc = irclib.Irc('test') + finally: + conf.supybot.networks.test.sasl.username.setValue('') + conf.supybot.networks.test.sasl.password.setValue('') + conf.supybot.networks.test.sasl.ecdsa_key.setValue('') + self.assertEqual(self.irc.sasl_current_mechanism, None) + self.assertEqual(self.irc.sasl_next_mechanisms, + ['ecdsa-nist256p-challenge', 'plain']) + + self.startCapNegociation() + + m = self.irc.takeMsg() + self.assertEqual(m, ircmsgs.IrcMsg(command='AUTHENTICATE', + args=('ECDSA-NIST256P-CHALLENGE',))) + + self.irc.feedMsg(ircmsgs.IrcMsg(command='904', + args=('mechanism not available',))) + + m = self.irc.takeMsg() + self.assertEqual(m, ircmsgs.IrcMsg(command='AUTHENTICATE', + args=('PLAIN',))) + + self.irc.feedMsg(ircmsgs.IrcMsg(command='AUTHENTICATE', args=('+',))) + + m = self.irc.takeMsg() + self.assertEqual(m, ircmsgs.IrcMsg(command='AUTHENTICATE', + args=('amlsbGVzAGppbGxlcwBzZXNhbWU=',))) + + self.irc.feedMsg(ircmsgs.IrcMsg(command='900', args=('jilles',))) + self.irc.feedMsg(ircmsgs.IrcMsg(command='903', args=('jilles',))) + + self.endCapNegociation()