diff --git a/src/conf.py b/src/conf.py index 7fc4e1719..42ecc0d83 100644 --- a/src/conf.py +++ b/src/conf.py @@ -312,7 +312,8 @@ class SpaceSeparatedSetOfChannels(registry.SpaceSeparatedListOf): return None class ValidSaslMechanism(registry.OnlySomeStrings): - validStrings = ('ecdsa-nist256p-challenge', 'external', 'plain') + validStrings = ('ecdsa-nist256p-challenge', 'external', 'plain', + 'scram-sha-256') class SpaceSeparatedListOfSaslMechanisms(registry.SpaceSeparatedListOf): Value = ValidSaslMechanism diff --git a/src/irclib.py b/src/irclib.py index 25bc73715..3a329d180 100644 --- a/src/irclib.py +++ b/src/irclib.py @@ -35,10 +35,14 @@ import base64 import collections try: - from ecdsa import SigningKey, BadDigestError - ecdsa = True + import ecdsa except ImportError: - ecdsa = False + ecdsa = None + +try: + import pyxmpp2_scram as scram +except ImportError: + scram = None from . import conf, ircdb, ircmsgs, ircutils, log, utils, world from .utils.str import rsplit @@ -994,6 +998,7 @@ class Irc(IrcCommandDispatcher, log.Firewalled): self.sasl_username = network_config.sasl.username() self.sasl_password = network_config.sasl.password() self.sasl_ecdsa_key = network_config.sasl.ecdsa_key() + self.sasl_scram_state = {'step': 'uninitialized'} self.authenticate_decoder = None self.sasl_next_mechanisms = [] self.sasl_current_mechanism = None @@ -1006,6 +1011,9 @@ class Irc(IrcCommandDispatcher, log.Firewalled): network_config.certfile() or conf.supybot.protocols.irc.certfile()): self.sasl_next_mechanisms.append(mechanism) + elif mechanism.startswith('scram-') and scram and \ + self.sasl_username and self.sasl_password: + self.sasl_next_mechanisms.append(mechanism) elif mechanism == 'plain' and \ self.sasl_username and self.sasl_password: self.sasl_next_mechanisms.append(mechanism) @@ -1101,20 +1109,30 @@ class Irc(IrcCommandDispatcher, log.Firewalled): mechanism = self.sasl_current_mechanism if mechanism == 'ecdsa-nist256p-challenge': - if string == b'': - self.sendSaslString(self.sasl_username.encode('utf-8')) - return + self.doAuthenticateEcdsa(string) + elif mechanism == 'external': + self.sendSaslString(b'') + elif mechanism.startswith('scram-'): + step = self.sasl_scram_state['step'] 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())) - self.sendSaslString(authstring) - except (BadDigestError, OSError, ValueError): + if step == 'uninitialized': + log.debug('%s: starting SCRAM.', + self.network) + self.doAuthenticateScramFirst(mechanism) + elif step == 'first-sent': + log.debug('%s: received SCRAM challenge.', + self.network) + self.doAuthenticateScramChallenge(string) + elif step == 'final-sent': + log.debug('%s: finishing SCRAM.', + self.network) + self.doAuthenticateScramFinish(string) + else: + assert False + except scram.ScramException: self.sendMsg(ircmsgs.IrcMsg(command='AUTHENTICATE', args=('*',))) self.tryNextSaslMechanism() - elif mechanism == 'external': - self.sendSaslString(b'') elif mechanism == 'plain': authstring = b'\0'.join([ self.sasl_username.encode('utf-8'), @@ -1123,6 +1141,58 @@ class Irc(IrcCommandDispatcher, log.Firewalled): ]) self.sendSaslString(authstring) + def doAuthenticateEcdsa(self, string): + if string == b'': + self.sendSaslString(self.sasl_username.encode('utf-8')) + return + try: + with open(self.sasl_ecdsa_key) as fd: + private_key = ecdsa.SigningKey.from_pem(fd.read()) + authstring = private_key.sign(string) + self.sendSaslString(authstring) + except (ecdsa.BadDigestError, OSError, ValueError): + self.sendMsg(ircmsgs.IrcMsg(command='AUTHENTICATE', + args=('*',))) + self.tryNextSaslMechanism() + + def doAuthenticateScramFirst(self, mechanism): + """Handle sending the client-first message of SCRAM auth.""" + hash_name = mechanism[len('scram-'):] + if hash_name.endswith('-plus'): + hash_name = hash_name[:-len('-plus')] + hash_name = hash_name.upper() + if hash_name not in scram.HASH_FACTORIES: + log.debug('%s: SCRAM hash %r not supported, aborting.', + self.network, hash_name) + self.tryNextSaslMechanism() + return + authenticator = scram.SCRAMClientAuthenticator(hash_name, + channel_binding=False) + self.sasl_scram_state['authenticator'] = authenticator + client_first = authenticator.start({ + 'username': self.sasl_username, + 'password': self.sasl_password, + }) + self.sendSaslString(client_first) + self.sasl_scram_state['step'] = 'first-sent' + + def doAuthenticateScramChallenge(self, challenge): + client_final = self.sasl_scram_state['authenticator'] \ + .challenge(challenge) + self.sendSaslString(client_final) + self.sasl_scram_state['step'] = 'final-sent' + + def doAuthenticateScramFinish(self, data): + try: + res = self.sasl_scram_state['authenticator'] \ + .finish(data) + except scram.BadSuccessException as e: + log.warning('%s: SASL authentication failed with SCRAM error: %e', + self.network, e) + self.tryNextSaslMechanism() + else: + self.sasl_scram_state['step'] = 'authenticated' + def do903(self, msg): log.info('%s: SASL authentication successful', self.network) self.sasl_authenticated = True diff --git a/src/registry.py b/src/registry.py index c924066f3..0404cf188 100644 --- a/src/registry.py +++ b/src/registry.py @@ -508,7 +508,7 @@ class Probability(Float): class String(Value): """Value is not a valid Python string.""" - errormsg = _('Value is not a valid Python string, not %r.') + errormsg = _('Value should be a valid Python string, not %r.') def set(self, s): v = s if not v: