From 9a8b345c53e852d7092197cee084d0d3c02bc0ff Mon Sep 17 00:00:00 2001 From: jesopo Date: Mon, 3 Jun 2019 12:44:04 +0100 Subject: Prefix names for all IRCv3 modules with "ircv3_" --- modules/chathistory.py | 29 -------- modules/ircv3_chathistory.py | 29 ++++++++ modules/ircv3_labeled_responses.py | 49 +++++++++++++ modules/ircv3_message_tracking.py | 17 +++++ modules/ircv3_metadata.py | 17 +++++ modules/ircv3_msgid.py | 13 ++++ modules/ircv3_resume.py | 84 +++++++++++++++++++++ modules/ircv3_sasl/README.md | 46 ++++++++++++ modules/ircv3_sasl/__init__.py | 147 +++++++++++++++++++++++++++++++++++++ modules/ircv3_sasl/scram.py | 130 ++++++++++++++++++++++++++++++++ modules/ircv3_server_time.py | 17 +++++ modules/ircv3_sts.py | 69 +++++++++++++++++ modules/labeled_responses.py | 49 ------------- modules/message_tracking.py | 17 ----- modules/metadata.py | 17 ----- modules/msgid.py | 13 ---- modules/resume.py | 84 --------------------- modules/sasl/README.md | 46 ------------ modules/sasl/__init__.py | 147 ------------------------------------- modules/sasl/scram.py | 130 -------------------------------- modules/server_time.py | 17 ----- modules/sts.py | 69 ----------------- 22 files changed, 618 insertions(+), 618 deletions(-) delete mode 100644 modules/chathistory.py create mode 100644 modules/ircv3_chathistory.py create mode 100644 modules/ircv3_labeled_responses.py create mode 100644 modules/ircv3_message_tracking.py create mode 100644 modules/ircv3_metadata.py create mode 100644 modules/ircv3_msgid.py create mode 100644 modules/ircv3_resume.py create mode 100644 modules/ircv3_sasl/README.md create mode 100644 modules/ircv3_sasl/__init__.py create mode 100644 modules/ircv3_sasl/scram.py create mode 100644 modules/ircv3_server_time.py create mode 100644 modules/ircv3_sts.py delete mode 100644 modules/labeled_responses.py delete mode 100644 modules/message_tracking.py delete mode 100644 modules/metadata.py delete mode 100644 modules/msgid.py delete mode 100644 modules/resume.py delete mode 100644 modules/sasl/README.md delete mode 100644 modules/sasl/__init__.py delete mode 100644 modules/sasl/scram.py delete mode 100644 modules/server_time.py delete mode 100644 modules/sts.py diff --git a/modules/chathistory.py b/modules/chathistory.py deleted file mode 100644 index 1f1711b1..00000000 --- a/modules/chathistory.py +++ /dev/null @@ -1,29 +0,0 @@ -#--depends-on msgid - -from src import ModuleManager, utils - -TAG = utils.irc.MessageTag("msgid", "draft/msgid") - -class Module(ModuleManager.BaseModule): - @utils.hook("received.batch.end") - def batch_end(self, event): - if event["batch"].type == "chathistory": - target_name = event["batch"].args[0] - if target_name in event["server"].channels: - target = event["server"].channels.get(target_name) - else: - target = event["server"].get_user(target_name) - - last_msgid = target.get_setting("last-msgid", None) - if not last_msgid == None: - lines = event["batch"].get_lines() - stop_index = -1 - - for i, line in enumerate(lines): - msgid = TAG.get_value(line.tags) - if msgid == last_msgid: - stop_index = i - break - - if not stop_index == -1: - return lines[stop_index+1:] diff --git a/modules/ircv3_chathistory.py b/modules/ircv3_chathistory.py new file mode 100644 index 00000000..1f1711b1 --- /dev/null +++ b/modules/ircv3_chathistory.py @@ -0,0 +1,29 @@ +#--depends-on msgid + +from src import ModuleManager, utils + +TAG = utils.irc.MessageTag("msgid", "draft/msgid") + +class Module(ModuleManager.BaseModule): + @utils.hook("received.batch.end") + def batch_end(self, event): + if event["batch"].type == "chathistory": + target_name = event["batch"].args[0] + if target_name in event["server"].channels: + target = event["server"].channels.get(target_name) + else: + target = event["server"].get_user(target_name) + + last_msgid = target.get_setting("last-msgid", None) + if not last_msgid == None: + lines = event["batch"].get_lines() + stop_index = -1 + + for i, line in enumerate(lines): + msgid = TAG.get_value(line.tags) + if msgid == last_msgid: + stop_index = i + break + + if not stop_index == -1: + return lines[stop_index+1:] diff --git a/modules/ircv3_labeled_responses.py b/modules/ircv3_labeled_responses.py new file mode 100644 index 00000000..eee53c58 --- /dev/null +++ b/modules/ircv3_labeled_responses.py @@ -0,0 +1,49 @@ +import uuid +from src import ModuleManager, utils + +CAP = utils.irc.Capability(None, "draft/labeled-response-0.2") +TAG = utils.irc.MessageTag(None, "draft/label") + +CAP_TO_TAG = { + "draft/labeled-response-0.2": "draft/label" +} + +class Module(ModuleManager.BaseModule): + @utils.hook("new.server") + def new_server(self, event): + event["server"]._label_cache = {} + + @utils.hook("received.cap.ls") + @utils.hook("received.cap.new") + def on_cap(self, event): + if CAP.available(event["capabilities"]): + return CAP.copy() + + @utils.hook("preprocess.send") + def raw_send(self, event): + available_cap = event["server"].available_capability(CAP) + + if available_cap: + label = TAG.get_value(event["line"].tags) + if label == None: + tag_key = CAP_TO_TAG[available_cap] + label = str(uuid.uuid4()) + event["line"].tags[tag_key] = label + + event["server"]._label_cache[label] = event["line"] + + @utils.hook("raw.received") + def raw_recv(self, event): + if not event["line"].command == "BATCH": + label = TAG.get_value(event["line"].tags) + if not label == None: + self._recv(event["server"], label, event["line"]) + + @utils.hook("received.batch.end") + def batch_end(self, event): + if TAG.match(event["batch"].type): + self._recv(event["server"], event["batch"].identifier, None) + + def _recv(self, server, label, line): + cached_line = server._label_cache.pop(label) + # do something with the line! diff --git a/modules/ircv3_message_tracking.py b/modules/ircv3_message_tracking.py new file mode 100644 index 00000000..3f4ad88c --- /dev/null +++ b/modules/ircv3_message_tracking.py @@ -0,0 +1,17 @@ +from src import ModuleManager, utils + +MSGID_TAG = "draft/msgid" +READ_TAG = "+draft/read" +DELIVERED_TAG = "+draft/delivered" +MESSAGE_TAG_CAPS = set(["draft/message-tags-0.2", "message-tags"]) + +class Module(ModuleManager.BaseModule): + @utils.hook("received.message.private") + @utils.hook("received.notice.private") + def privmsg(self, event): + if MSGID_TAG in event["tags"] and ( + event["server"].agreed_capabilities & MESSAGE_TAG_CAPS): + target = event.get("channel", event["user"]) + msgid = event["tags"][MSGID_TAG] + tags = {DELIVERED_TAG: msgid, READ_TAG: msgid} + target.send_tagmsg(tags) diff --git a/modules/ircv3_metadata.py b/modules/ircv3_metadata.py new file mode 100644 index 00000000..55193dce --- /dev/null +++ b/modules/ircv3_metadata.py @@ -0,0 +1,17 @@ +from src import IRCBot, ModuleManager, utils + +CAP = utils.irc.Capability(None, "draft/metadata") + +class Module(ModuleManager.BaseModule): + @utils.hook("received.cap.new") + @utils.hook("received.cap.ls") + def on_cap(self, event): + cap = CAP.copy() + if cap.available(event["capabilities"]): + cap.on_ack(lambda: self._ack(event["server"])) + return cap + + def _ack(self, server): + url = self.bot.get_setting("bot-url", IRCBot.SOURCE) + server.send_raw("METADATA * SET bot BitBot") + server.send_raw("METADATA * SET homepage :%s" % url) diff --git a/modules/ircv3_msgid.py b/modules/ircv3_msgid.py new file mode 100644 index 00000000..7edeed78 --- /dev/null +++ b/modules/ircv3_msgid.py @@ -0,0 +1,13 @@ +from src import ModuleManager, utils + +TAG = utils.irc.MessageTag("msgid", "draft/msgid") + +class Module(ModuleManager.BaseModule): + @utils.hook("received.message.channel") + #TODO: catch CTCPs + @utils.hook("received.notice.channel") + @utils.hook("received.tagmsg.channel") + def on_channel(self, event): + msgid = TAG.get_value(event["tags"]) + if not msgid == None: + event["channel"].set_setting("last-msgid", msgid) diff --git a/modules/ircv3_resume.py b/modules/ircv3_resume.py new file mode 100644 index 00000000..f73c9e5b --- /dev/null +++ b/modules/ircv3_resume.py @@ -0,0 +1,84 @@ +#--depends-on server_time + +from src import ModuleManager, utils + +CAP = utils.irc.Capability(None, "draft/resume-0.5") + +class Module(ModuleManager.BaseModule): + def _setting(self, new): + return "resume-token%s" % ("-new" if new else "") + def _get_token(self, server, new=False): + return server.get_setting(self._setting(new), None) + def _set_token(self, server, token, new=False): + server.set_setting(self._setting(new), token) + def _del_token(self, server, new=False): + server.del_setting(self._setting(new)) + + + @utils.hook("new.server") + def new_server(self, event): + # we need to pull this before any data has been exchanged - to make sure + # it's not overwritten from the last connection + event["server"]._resume_timestamp = event["server"].get_setting( + "last-server-time", None) + + @utils.hook("received.cap.ls") + def on_cap_ls(self, event): + if CAP.available(event["capabilities"]): + cap = CAP.copy() + cap.on_ack(lambda: self._cap_ack(event["server"])) + return cap + + def _cap_ack(self, server): + server.wait_for_capability("resume") + + @utils.hook("received.resume") + def on_resume(self, event): + cap_done = True + + if event["args"][0] == "SUCCESS": + resume_channels = event["server"].get_setting("resume-channels", []) + self.log.info("Successfully resumed session", []) + event["server"].cap_started = False + + elif event["args"][0] == "TOKEN": + token = self._get_token(event["server"]) + self._set_token(event["server"], event["args"][1], new=True) + + if token: + timestamp = event["server"]._resume_timestamp + + event["server"].send_raw("RESUME %s%s" % + (token, " %s" % timestamp if timestamp else "")) + cap_done = False + + if cap_done: + event["server"].capability_done("resume") + + + @utils.hook("received.001") + def on_connect(self, event): + event["server"].del_setting("resume-channels") + + new_token = self._get_token(event["server"], new=True) + if new_token: + self._set_token(event["server"], new_token) + self._del_token(event["server"], new=True) + + @utils.hook("self.join") + def on_join(self, event): + resume_channels = event["server"].get_setting("resume-channels", []) + channel_name = event["server"].irc_lower(event["channel"].name) + if not channel_name in resume_channels: + resume_channels.append(channel_name) + event["server"].set_setting("resume-channels", resume_channels) + + @utils.hook("preprocess.send.quit") + def preprocess_send(self, event): + if event["line"].command == "QUIT" and event["server"].has_capability( + CAP): + event["line"].command = "BRB" + + @utils.hook("received.fail.resume") + def fail_resume(self, event): + event["server"].capability_done("resume") diff --git a/modules/ircv3_sasl/README.md b/modules/ircv3_sasl/README.md new file mode 100644 index 00000000..30a51e08 --- /dev/null +++ b/modules/ircv3_sasl/README.md @@ -0,0 +1,46 @@ +# Configuring SASL + +You can either configure SASL through `!serverset sasl` from an registered and identified admin account or directly through sqlite. + +## USERPASS Mechanism + +BitBot supports a special SASL mechanism name: `USERPASS`. This internally +represents "pick the strongest username:password algorithm" + +## !serverset sasl + +These commands are to be executed from a registered admin account + +#### USERPASS +> !serverset sasl userpass <username>:<password> + +#### PLAIN +> !serverset sasl plain <username>:<password> + +#### SCRAM-SHA-1 +> !serverset sasl scram-sha-1 <username>:<password> + +#### SCRAM-SHA-256 +> !serverset sasl scram-sha-256 <username>:<password> + +#### EXTERNAL +> !serverset sasl external + +## sqlite + +Execute these against the current bot database file (e.g. `$ sqlite3 databases/bot.db`) + +#### USERPASS +> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "userpass", "args": "<username>:<password>"}'); + +#### PLAIN +> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "plain", "args": "<username>:<password>"}'); + +#### SCRAM-SHA-1 +> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "scram-sha-1", "args": "<username>:<password>"}'); + +#### SCRAM-SHA-256 +> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "scram-sha-256", "args": "<username>:<password>"}'); + +#### external +> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "external"}'); diff --git a/modules/ircv3_sasl/__init__.py b/modules/ircv3_sasl/__init__.py new file mode 100644 index 00000000..b62309a6 --- /dev/null +++ b/modules/ircv3_sasl/__init__.py @@ -0,0 +1,147 @@ +#--depends-on config + +import base64, hashlib, hmac, uuid +from src import ModuleManager, utils +from . import scram + +CAP = utils.irc.Capability("sasl") + +USERPASS_MECHANISMS = [ + "SCRAM-SHA-512", + "SCRAM-SHA-256", + "SCRAM-SHA-1", + "PLAIN" +] + +def _validate(s): + mechanism, _, arguments = s.partition(" ") + return {"mechanism": mechanism, "args": arguments} + +@utils.export("serverset", {"setting": "sasl", + "help": "Set the sasl username/password for this server", + "validate": _validate, "example": "PLAIN BitBot:hunder2"}) +class Module(ModuleManager.BaseModule): + def _best_userpass_mechanism(self, mechanisms): + for potential_mechanism in USERPASS_MECHANISMS: + if potential_mechanism in mechanisms: + return potential_mechanism + + @utils.hook("received.cap.new") + @utils.hook("received.cap.ls") + def on_cap(self, event): + has_sasl = "sasl" in event["capabilities"] + our_sasl = event["server"].get_setting("sasl", None) + + do_sasl = False + if has_sasl and our_sasl: + if not event["capabilities"]["sasl"] == None: + our_mechanism = our_sasl["mechanism"].upper() + server_mechanisms = event["capabilities"]["sasl"].split(",") + if our_mechanism == "USERPASS": + our_mechanism = self._best_userpass_mechanism( + server_mechanisms) + do_sasl = our_mechanism in server_mechanisms + else: + do_sasl = True + + if do_sasl: + cap = CAP.copy() + cap.on_ack(lambda: self._sasl_ack(event["server"])) + return cap + + def _sasl_ack(self, server): + sasl = server.get_setting("sasl") + mechanism = sasl["mechanism"].upper() + if mechanism == "USERPASS": + server_mechanisms = server.server_capabilities["sasl"] + server_mechanisms = server_mechanisms or [ + USERPASS_MECHANISMS[0]] + mechanism = self._best_userpass_mechanism(server_mechanisms) + + server.send_authenticate(mechanism) + server.sasl_mechanism = mechanism + server.wait_for_capability("sasl") + + @utils.hook("received.authenticate") + def on_authenticate(self, event): + sasl = event["server"].get_setting("sasl") + mechanism = event["server"].sasl_mechanism + + auth_text = None + if mechanism == "PLAIN": + if event["message"] != "+": + event["server"].send_authenticate("*") + else: + sasl_username, sasl_password = sasl["args"].split(":", 1) + auth_text = ("%s\0%s\0%s" % ( + sasl_username, sasl_username, sasl_password)).encode("utf8") + + elif mechanism == "EXTERNAL": + if event["message"] != "+": + event["server"].send_authenticate("*") + else: + auth_text = "+" + + elif mechanism.startswith("SCRAM-"): + + if event["message"] == "+": + # start SCRAM handshake + + # create SCRAM helper + sasl_username, sasl_password = sasl["args"].split(":", 1) + algo = mechanism.split("SCRAM-", 1)[1] + event["server"]._scram = scram.SCRAM( + algo, sasl_username, sasl_password) + + # generate client-first-message + auth_text = event["server"]._scram.client_first() + else: + current_scram = event["server"]._scram + data = base64.b64decode(event["message"]) + if current_scram.state == scram.SCRAMState.ClientFirst: + # use server-first-message to generate client-final-message + auth_text = current_scram.server_first(data) + elif current_scram.state == scram.SCRAMState.ClientFinal: + # use server-final-message to check server proof + verified = current_scram.server_final(data) + del event["server"]._scram + + if verified: + auth_text = "+" + else: + if current_scram.state == scram.SCRAMState.VerifyFailed: + # server gave a bad verification so we should panic + event["server"].disconnect() + raise ValueError("Server SCRAM verification failed") + + else: + raise ValueError("unknown sasl mechanism '%s'" % mechanism) + + if not auth_text == None: + if not auth_text == "+": + auth_text = base64.b64encode(auth_text) + auth_text = auth_text.decode("utf8") + event["server"].send_authenticate(auth_text) + + def _end_sasl(self, server): + server.capability_done("sasl") + + @utils.hook("received.908") + def sasl_mechanisms(self, event): + server_mechanisms = event["args"][1].split(",") + mechanism = self._best_userpass_mechanism(server_mechanimsms) + event["server"].sasl_mechanism = mechanism + event["server"].send_authenticate(mechanism) + + @utils.hook("received.903") + def sasl_success(self, event): + self._end_sasl(event["server"]) + @utils.hook("received.904") + def sasl_failure(self, event): + self.log.warn("SASL failure for %s: %s", + [str(event["server"]), event["args"][1]]) + self._end_sasl(event["server"]) + + @utils.hook("received.907") + def sasl_already(self, event): + self._end_sasl(event["server"]) diff --git a/modules/ircv3_sasl/scram.py b/modules/ircv3_sasl/scram.py new file mode 100644 index 00000000..f243d1e6 --- /dev/null +++ b/modules/ircv3_sasl/scram.py @@ -0,0 +1,130 @@ +import base64, enum, hashlib, hmac, os, typing + +# IANA Hash Function Textual Names +# https://tools.ietf.org/html/rfc5802#section-4 +# https://www.iana.org/assignments/hash-function-text-names/ +# MD2 has been removed as it's unacceptably weak +ALGORITHMS = [ + "MD5", "SHA-1", "SHA-224", "SHA-256", "SHA-384", "SHA-512"] + +SCRAM_ERRORS = [ + "invalid-encoding", + "extensions-not-supported", # unrecognized 'm' value + "invalid-proof", + "channel-bindings-dont-match", + "server-does-support-channel-binding", + "channel-binding-not-supported", + "unsupported-channel-binding-type", + "unknown-user", + "invalid-username-encoding", # invalid utf8 or bad SASLprep + "no-resources" +] + +def _scram_nonce() -> bytes: + return base64.b64encode(os.urandom(32)) +def _scram_escape(s: bytes) -> bytes: + return s.replace(b"=", b"=3D").replace(b",", b"=2C") +def _scram_unescape(s: bytes) -> bytes: + return s.replace(b"=3D", b"=").replace(b"=2C", b",") +def _scram_xor(s1: bytes, s2: bytes) -> bytes: + return bytes(a ^ b for a, b in zip(s1, s2)) + +class SCRAMState(enum.Enum): + Uninitialised = 0 + ClientFirst = 1 + ClientFinal = 2 + Success = 3 + Failed = 4 + VerifyFailed = 5 + +class SCRAMError(Exception): + pass + +class SCRAM(object): + def __init__(self, algo: str, username: str, password: str): + if not algo in ALGORITHMS: + raise ValueError("Unknown SCRAM algorithm '%s'" % algo) + + self._algo = algo.replace("-", "") # SHA-1 -> SHA1 + self._username = username.encode("utf8") + self._password = password.encode("utf8") + + self.state = SCRAMState.Uninitialised + self.error = "" + self.raw_error = "" + + self._client_first = b"" + self._salted_password = b"" + self._auth_message = b"" + + def _get_pieces(self, data: bytes) -> typing.Dict[bytes, bytes]: + pieces = (piece.split(b"=", 1) for piece in data.split(b",")) + return dict((piece[0], piece[1]) for piece in pieces) + + def _hmac(self, key: bytes, msg: bytes) -> bytes: + return hmac.new(key, msg, self._algo).digest() + def _hash(self, msg: bytes) -> bytes: + return hashlib.new(self._algo, msg).digest() + + def _constant_time_compare(self, b1: bytes, b2: bytes): + return hmac.compare_digest(b1, b2) + + def client_first(self) -> bytes: + self.state = SCRAMState.ClientFirst + self._client_first = b"n=%s,r=%s" % ( + _scram_escape(self._username), _scram_nonce()) + + # n,,n=,r= + return b"n,,%s" % self._client_first + + def server_first(self, data: bytes) -> bytes: + self.state = SCRAMState.ClientFinal + + pieces = self._get_pieces(data) + nonce = pieces[b"r"] # server combines your nonce with it's own + salt = base64.b64decode(pieces[b"s"]) # salt is b64encoded + iterations = int(pieces[b"i"]) + + salted_password = hashlib.pbkdf2_hmac(self._algo, self._password, + salt, iterations, dklen=None) + self._salted_password = salted_password + + client_key = self._hmac(salted_password, b"Client Key") + stored_key = self._hash(client_key) + + channel = base64.b64encode(b"n,,") + auth_noproof = b"c=%s,r=%s" % (channel, nonce) + auth_message = b"%s,%s,%s" % (self._client_first, data, auth_noproof) + self._auth_message = auth_message + + client_signature = self._hmac(stored_key, auth_message) + client_proof_xor = _scram_xor(client_key, client_signature) + client_proof = base64.b64encode(client_proof_xor) + + # c=,r=,p= + return b"%s,p=%s" % (auth_noproof, client_proof) + + def server_final(self, data: bytes) -> bool: + pieces = self._get_pieces(data) + if b"e" in pieces: + error = pieces[b"e"].decode("utf8") + self.raw_error = error + if error in SCRAM_ERRORS: + self.error = error + else: + self.error = "other-error" + + self.state = SCRAMState.Failed + return False + + verifier = base64.b64decode(pieces[b"v"]) + + server_key = self._hmac(self._salted_password, b"Server Key") + server_signature = self._hmac(server_key, self._auth_message) + + if server_signature == verifier: + self.state = SCRAMState.Success + return True + else: + self.state = SCRAMState.VerifyFailed + return False diff --git a/modules/ircv3_server_time.py b/modules/ircv3_server_time.py new file mode 100644 index 00000000..e363b341 --- /dev/null +++ b/modules/ircv3_server_time.py @@ -0,0 +1,17 @@ +from src import ModuleManager, utils + +CAP = utils.irc.Capability("server-time") +TAG = utils.irc.MessageTag("time") + +class Module(ModuleManager.BaseModule): + @utils.hook("received.cap.ls") + @utils.hook("received.cap.new") + def on_cap(self, event): + if CAP.available(event["capabilities"]): + return CAP.copy() + + @utils.hook("raw.received") + def raw_recv(self, event): + server_time = TAG.get_value(event["line"].tags) + if not server_time == None: + event["server"].set_setting("last-server-time", server_time) diff --git a/modules/ircv3_sts.py b/modules/ircv3_sts.py new file mode 100644 index 00000000..09ecf523 --- /dev/null +++ b/modules/ircv3_sts.py @@ -0,0 +1,69 @@ +import time +from src import ModuleManager, utils + +CAP = utils.irc.Capability("sts", "draft/sts") + +class Module(ModuleManager.BaseModule): + def _get_policy(self, server): + return server.get_setting("sts-policy", None) + def _set_policy(self, server, policy): + self.log.info("Setting STS policy for '%s': %s", [str(server), policy]) + server.set_setting("sts-policy", policy) + def _remove_policy(self, server): + server.del_setting("sts-policy") + + def set_policy(self, server, port, duration): + expiration = None + self._set_policy(server, { + "port": port, + "from": time.time(), + "duration": duration}) + def change_duration(self, server, info): + duration = int(info["duration"]) + if duration == 0: + self._remove_policy(server) + else: + port = server.connection_params.port + if "port" in info: + port = int(info["port"]) + self.set_policy(server, port, duration) + + @utils.hook("received.cap.ls") + def on_cap_ls(self, event): + sts = CAP.available(event["capabilities"]) + if sts: + info = utils.parse.keyvalue(event["capabilities"][sts], + delimiter=",") + if not event["server"].connection_params.tls: + self.set_policy(event["server"], int(info["port"]), None) + event["server"].disconnect() + self.bot.reconnect(event["server"].id, + event["server"].connection_params) + else: + self.change_duration(event["server"], info) + + @utils.hook("received.cap.new") + def on_cap_new(self, event): + sts = self._get_sts(event["capabilities"]) + if sts and event["server"].connection_params.tls: + info = utils.parse.keyvalue(sts, delimiter=",") + self.change_duration(event["server"], info) + + @utils.hook("new.server") + def new_server(self, event): + sts_policy = self._get_policy(event["server"]) + if sts_policy: + if not event["server"].connection_params.tls: + if not sts_policy["duration"] or time.time() <= ( + sts_policy["from"]+sts_policy["duration"]): + self.log.info("Applying STS policy for '%s'", + [str(event["server"])]) + event["server"].connection_params.tls = True + event["server"].connection_params.port = sts_policy["port"] + + @utils.hook("server.disconnect") + def on_disconnect(self, event): + sts_policy = self._get_policy(event["server"]) + if sts_policy and sts_policy["duration"]: + sts_policy["from"] = time.time() + self._set_policy(event["server"], sts_policy) diff --git a/modules/labeled_responses.py b/modules/labeled_responses.py deleted file mode 100644 index eee53c58..00000000 --- a/modules/labeled_responses.py +++ /dev/null @@ -1,49 +0,0 @@ -import uuid -from src import ModuleManager, utils - -CAP = utils.irc.Capability(None, "draft/labeled-response-0.2") -TAG = utils.irc.MessageTag(None, "draft/label") - -CAP_TO_TAG = { - "draft/labeled-response-0.2": "draft/label" -} - -class Module(ModuleManager.BaseModule): - @utils.hook("new.server") - def new_server(self, event): - event["server"]._label_cache = {} - - @utils.hook("received.cap.ls") - @utils.hook("received.cap.new") - def on_cap(self, event): - if CAP.available(event["capabilities"]): - return CAP.copy() - - @utils.hook("preprocess.send") - def raw_send(self, event): - available_cap = event["server"].available_capability(CAP) - - if available_cap: - label = TAG.get_value(event["line"].tags) - if label == None: - tag_key = CAP_TO_TAG[available_cap] - label = str(uuid.uuid4()) - event["line"].tags[tag_key] = label - - event["server"]._label_cache[label] = event["line"] - - @utils.hook("raw.received") - def raw_recv(self, event): - if not event["line"].command == "BATCH": - label = TAG.get_value(event["line"].tags) - if not label == None: - self._recv(event["server"], label, event["line"]) - - @utils.hook("received.batch.end") - def batch_end(self, event): - if TAG.match(event["batch"].type): - self._recv(event["server"], event["batch"].identifier, None) - - def _recv(self, server, label, line): - cached_line = server._label_cache.pop(label) - # do something with the line! diff --git a/modules/message_tracking.py b/modules/message_tracking.py deleted file mode 100644 index 3f4ad88c..00000000 --- a/modules/message_tracking.py +++ /dev/null @@ -1,17 +0,0 @@ -from src import ModuleManager, utils - -MSGID_TAG = "draft/msgid" -READ_TAG = "+draft/read" -DELIVERED_TAG = "+draft/delivered" -MESSAGE_TAG_CAPS = set(["draft/message-tags-0.2", "message-tags"]) - -class Module(ModuleManager.BaseModule): - @utils.hook("received.message.private") - @utils.hook("received.notice.private") - def privmsg(self, event): - if MSGID_TAG in event["tags"] and ( - event["server"].agreed_capabilities & MESSAGE_TAG_CAPS): - target = event.get("channel", event["user"]) - msgid = event["tags"][MSGID_TAG] - tags = {DELIVERED_TAG: msgid, READ_TAG: msgid} - target.send_tagmsg(tags) diff --git a/modules/metadata.py b/modules/metadata.py deleted file mode 100644 index 55193dce..00000000 --- a/modules/metadata.py +++ /dev/null @@ -1,17 +0,0 @@ -from src import IRCBot, ModuleManager, utils - -CAP = utils.irc.Capability(None, "draft/metadata") - -class Module(ModuleManager.BaseModule): - @utils.hook("received.cap.new") - @utils.hook("received.cap.ls") - def on_cap(self, event): - cap = CAP.copy() - if cap.available(event["capabilities"]): - cap.on_ack(lambda: self._ack(event["server"])) - return cap - - def _ack(self, server): - url = self.bot.get_setting("bot-url", IRCBot.SOURCE) - server.send_raw("METADATA * SET bot BitBot") - server.send_raw("METADATA * SET homepage :%s" % url) diff --git a/modules/msgid.py b/modules/msgid.py deleted file mode 100644 index 7edeed78..00000000 --- a/modules/msgid.py +++ /dev/null @@ -1,13 +0,0 @@ -from src import ModuleManager, utils - -TAG = utils.irc.MessageTag("msgid", "draft/msgid") - -class Module(ModuleManager.BaseModule): - @utils.hook("received.message.channel") - #TODO: catch CTCPs - @utils.hook("received.notice.channel") - @utils.hook("received.tagmsg.channel") - def on_channel(self, event): - msgid = TAG.get_value(event["tags"]) - if not msgid == None: - event["channel"].set_setting("last-msgid", msgid) diff --git a/modules/resume.py b/modules/resume.py deleted file mode 100644 index f73c9e5b..00000000 --- a/modules/resume.py +++ /dev/null @@ -1,84 +0,0 @@ -#--depends-on server_time - -from src import ModuleManager, utils - -CAP = utils.irc.Capability(None, "draft/resume-0.5") - -class Module(ModuleManager.BaseModule): - def _setting(self, new): - return "resume-token%s" % ("-new" if new else "") - def _get_token(self, server, new=False): - return server.get_setting(self._setting(new), None) - def _set_token(self, server, token, new=False): - server.set_setting(self._setting(new), token) - def _del_token(self, server, new=False): - server.del_setting(self._setting(new)) - - - @utils.hook("new.server") - def new_server(self, event): - # we need to pull this before any data has been exchanged - to make sure - # it's not overwritten from the last connection - event["server"]._resume_timestamp = event["server"].get_setting( - "last-server-time", None) - - @utils.hook("received.cap.ls") - def on_cap_ls(self, event): - if CAP.available(event["capabilities"]): - cap = CAP.copy() - cap.on_ack(lambda: self._cap_ack(event["server"])) - return cap - - def _cap_ack(self, server): - server.wait_for_capability("resume") - - @utils.hook("received.resume") - def on_resume(self, event): - cap_done = True - - if event["args"][0] == "SUCCESS": - resume_channels = event["server"].get_setting("resume-channels", []) - self.log.info("Successfully resumed session", []) - event["server"].cap_started = False - - elif event["args"][0] == "TOKEN": - token = self._get_token(event["server"]) - self._set_token(event["server"], event["args"][1], new=True) - - if token: - timestamp = event["server"]._resume_timestamp - - event["server"].send_raw("RESUME %s%s" % - (token, " %s" % timestamp if timestamp else "")) - cap_done = False - - if cap_done: - event["server"].capability_done("resume") - - - @utils.hook("received.001") - def on_connect(self, event): - event["server"].del_setting("resume-channels") - - new_token = self._get_token(event["server"], new=True) - if new_token: - self._set_token(event["server"], new_token) - self._del_token(event["server"], new=True) - - @utils.hook("self.join") - def on_join(self, event): - resume_channels = event["server"].get_setting("resume-channels", []) - channel_name = event["server"].irc_lower(event["channel"].name) - if not channel_name in resume_channels: - resume_channels.append(channel_name) - event["server"].set_setting("resume-channels", resume_channels) - - @utils.hook("preprocess.send.quit") - def preprocess_send(self, event): - if event["line"].command == "QUIT" and event["server"].has_capability( - CAP): - event["line"].command = "BRB" - - @utils.hook("received.fail.resume") - def fail_resume(self, event): - event["server"].capability_done("resume") diff --git a/modules/sasl/README.md b/modules/sasl/README.md deleted file mode 100644 index 30a51e08..00000000 --- a/modules/sasl/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# Configuring SASL - -You can either configure SASL through `!serverset sasl` from an registered and identified admin account or directly through sqlite. - -## USERPASS Mechanism - -BitBot supports a special SASL mechanism name: `USERPASS`. This internally -represents "pick the strongest username:password algorithm" - -## !serverset sasl - -These commands are to be executed from a registered admin account - -#### USERPASS -> !serverset sasl userpass <username>:<password> - -#### PLAIN -> !serverset sasl plain <username>:<password> - -#### SCRAM-SHA-1 -> !serverset sasl scram-sha-1 <username>:<password> - -#### SCRAM-SHA-256 -> !serverset sasl scram-sha-256 <username>:<password> - -#### EXTERNAL -> !serverset sasl external - -## sqlite - -Execute these against the current bot database file (e.g. `$ sqlite3 databases/bot.db`) - -#### USERPASS -> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "userpass", "args": "<username>:<password>"}'); - -#### PLAIN -> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "plain", "args": "<username>:<password>"}'); - -#### SCRAM-SHA-1 -> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "scram-sha-1", "args": "<username>:<password>"}'); - -#### SCRAM-SHA-256 -> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "scram-sha-256", "args": "<username>:<password>"}'); - -#### external -> INSERT INTO server_settings (<serverid>, 'sasl', '{"mechanism": "external"}'); diff --git a/modules/sasl/__init__.py b/modules/sasl/__init__.py deleted file mode 100644 index b62309a6..00000000 --- a/modules/sasl/__init__.py +++ /dev/null @@ -1,147 +0,0 @@ -#--depends-on config - -import base64, hashlib, hmac, uuid -from src import ModuleManager, utils -from . import scram - -CAP = utils.irc.Capability("sasl") - -USERPASS_MECHANISMS = [ - "SCRAM-SHA-512", - "SCRAM-SHA-256", - "SCRAM-SHA-1", - "PLAIN" -] - -def _validate(s): - mechanism, _, arguments = s.partition(" ") - return {"mechanism": mechanism, "args": arguments} - -@utils.export("serverset", {"setting": "sasl", - "help": "Set the sasl username/password for this server", - "validate": _validate, "example": "PLAIN BitBot:hunder2"}) -class Module(ModuleManager.BaseModule): - def _best_userpass_mechanism(self, mechanisms): - for potential_mechanism in USERPASS_MECHANISMS: - if potential_mechanism in mechanisms: - return potential_mechanism - - @utils.hook("received.cap.new") - @utils.hook("received.cap.ls") - def on_cap(self, event): - has_sasl = "sasl" in event["capabilities"] - our_sasl = event["server"].get_setting("sasl", None) - - do_sasl = False - if has_sasl and our_sasl: - if not event["capabilities"]["sasl"] == None: - our_mechanism = our_sasl["mechanism"].upper() - server_mechanisms = event["capabilities"]["sasl"].split(",") - if our_mechanism == "USERPASS": - our_mechanism = self._best_userpass_mechanism( - server_mechanisms) - do_sasl = our_mechanism in server_mechanisms - else: - do_sasl = True - - if do_sasl: - cap = CAP.copy() - cap.on_ack(lambda: self._sasl_ack(event["server"])) - return cap - - def _sasl_ack(self, server): - sasl = server.get_setting("sasl") - mechanism = sasl["mechanism"].upper() - if mechanism == "USERPASS": - server_mechanisms = server.server_capabilities["sasl"] - server_mechanisms = server_mechanisms or [ - USERPASS_MECHANISMS[0]] - mechanism = self._best_userpass_mechanism(server_mechanisms) - - server.send_authenticate(mechanism) - server.sasl_mechanism = mechanism - server.wait_for_capability("sasl") - - @utils.hook("received.authenticate") - def on_authenticate(self, event): - sasl = event["server"].get_setting("sasl") - mechanism = event["server"].sasl_mechanism - - auth_text = None - if mechanism == "PLAIN": - if event["message"] != "+": - event["server"].send_authenticate("*") - else: - sasl_username, sasl_password = sasl["args"].split(":", 1) - auth_text = ("%s\0%s\0%s" % ( - sasl_username, sasl_username, sasl_password)).encode("utf8") - - elif mechanism == "EXTERNAL": - if event["message"] != "+": - event["server"].send_authenticate("*") - else: - auth_text = "+" - - elif mechanism.startswith("SCRAM-"): - - if event["message"] == "+": - # start SCRAM handshake - - # create SCRAM helper - sasl_username, sasl_password = sasl["args"].split(":", 1) - algo = mechanism.split("SCRAM-", 1)[1] - event["server"]._scram = scram.SCRAM( - algo, sasl_username, sasl_password) - - # generate client-first-message - auth_text = event["server"]._scram.client_first() - else: - current_scram = event["server"]._scram - data = base64.b64decode(event["message"]) - if current_scram.state == scram.SCRAMState.ClientFirst: - # use server-first-message to generate client-final-message - auth_text = current_scram.server_first(data) - elif current_scram.state == scram.SCRAMState.ClientFinal: - # use server-final-message to check server proof - verified = current_scram.server_final(data) - del event["server"]._scram - - if verified: - auth_text = "+" - else: - if current_scram.state == scram.SCRAMState.VerifyFailed: - # server gave a bad verification so we should panic - event["server"].disconnect() - raise ValueError("Server SCRAM verification failed") - - else: - raise ValueError("unknown sasl mechanism '%s'" % mechanism) - - if not auth_text == None: - if not auth_text == "+": - auth_text = base64.b64encode(auth_text) - auth_text = auth_text.decode("utf8") - event["server"].send_authenticate(auth_text) - - def _end_sasl(self, server): - server.capability_done("sasl") - - @utils.hook("received.908") - def sasl_mechanisms(self, event): - server_mechanisms = event["args"][1].split(",") - mechanism = self._best_userpass_mechanism(server_mechanimsms) - event["server"].sasl_mechanism = mechanism - event["server"].send_authenticate(mechanism) - - @utils.hook("received.903") - def sasl_success(self, event): - self._end_sasl(event["server"]) - @utils.hook("received.904") - def sasl_failure(self, event): - self.log.warn("SASL failure for %s: %s", - [str(event["server"]), event["args"][1]]) - self._end_sasl(event["server"]) - - @utils.hook("received.907") - def sasl_already(self, event): - self._end_sasl(event["server"]) diff --git a/modules/sasl/scram.py b/modules/sasl/scram.py deleted file mode 100644 index f243d1e6..00000000 --- a/modules/sasl/scram.py +++ /dev/null @@ -1,130 +0,0 @@ -import base64, enum, hashlib, hmac, os, typing - -# IANA Hash Function Textual Names -# https://tools.ietf.org/html/rfc5802#section-4 -# https://www.iana.org/assignments/hash-function-text-names/ -# MD2 has been removed as it's unacceptably weak -ALGORITHMS = [ - "MD5", "SHA-1", "SHA-224", "SHA-256", "SHA-384", "SHA-512"] - -SCRAM_ERRORS = [ - "invalid-encoding", - "extensions-not-supported", # unrecognized 'm' value - "invalid-proof", - "channel-bindings-dont-match", - "server-does-support-channel-binding", - "channel-binding-not-supported", - "unsupported-channel-binding-type", - "unknown-user", - "invalid-username-encoding", # invalid utf8 or bad SASLprep - "no-resources" -] - -def _scram_nonce() -> bytes: - return base64.b64encode(os.urandom(32)) -def _scram_escape(s: bytes) -> bytes: - return s.replace(b"=", b"=3D").replace(b",", b"=2C") -def _scram_unescape(s: bytes) -> bytes: - return s.replace(b"=3D", b"=").replace(b"=2C", b",") -def _scram_xor(s1: bytes, s2: bytes) -> bytes: - return bytes(a ^ b for a, b in zip(s1, s2)) - -class SCRAMState(enum.Enum): - Uninitialised = 0 - ClientFirst = 1 - ClientFinal = 2 - Success = 3 - Failed = 4 - VerifyFailed = 5 - -class SCRAMError(Exception): - pass - -class SCRAM(object): - def __init__(self, algo: str, username: str, password: str): - if not algo in ALGORITHMS: - raise ValueError("Unknown SCRAM algorithm '%s'" % algo) - - self._algo = algo.replace("-", "") # SHA-1 -> SHA1 - self._username = username.encode("utf8") - self._password = password.encode("utf8") - - self.state = SCRAMState.Uninitialised - self.error = "" - self.raw_error = "" - - self._client_first = b"" - self._salted_password = b"" - self._auth_message = b"" - - def _get_pieces(self, data: bytes) -> typing.Dict[bytes, bytes]: - pieces = (piece.split(b"=", 1) for piece in data.split(b",")) - return dict((piece[0], piece[1]) for piece in pieces) - - def _hmac(self, key: bytes, msg: bytes) -> bytes: - return hmac.new(key, msg, self._algo).digest() - def _hash(self, msg: bytes) -> bytes: - return hashlib.new(self._algo, msg).digest() - - def _constant_time_compare(self, b1: bytes, b2: bytes): - return hmac.compare_digest(b1, b2) - - def client_first(self) -> bytes: - self.state = SCRAMState.ClientFirst - self._client_first = b"n=%s,r=%s" % ( - _scram_escape(self._username), _scram_nonce()) - - # n,,n=,r= - return b"n,,%s" % self._client_first - - def server_first(self, data: bytes) -> bytes: - self.state = SCRAMState.ClientFinal - - pieces = self._get_pieces(data) - nonce = pieces[b"r"] # server combines your nonce with it's own - salt = base64.b64decode(pieces[b"s"]) # salt is b64encoded - iterations = int(pieces[b"i"]) - - salted_password = hashlib.pbkdf2_hmac(self._algo, self._password, - salt, iterations, dklen=None) - self._salted_password = salted_password - - client_key = self._hmac(salted_password, b"Client Key") - stored_key = self._hash(client_key) - - channel = base64.b64encode(b"n,,") - auth_noproof = b"c=%s,r=%s" % (channel, nonce) - auth_message = b"%s,%s,%s" % (self._client_first, data, auth_noproof) - self._auth_message = auth_message - - client_signature = self._hmac(stored_key, auth_message) - client_proof_xor = _scram_xor(client_key, client_signature) - client_proof = base64.b64encode(client_proof_xor) - - # c=,r=,p= - return b"%s,p=%s" % (auth_noproof, client_proof) - - def server_final(self, data: bytes) -> bool: - pieces = self._get_pieces(data) - if b"e" in pieces: - error = pieces[b"e"].decode("utf8") - self.raw_error = error - if error in SCRAM_ERRORS: - self.error = error - else: - self.error = "other-error" - - self.state = SCRAMState.Failed - return False - - verifier = base64.b64decode(pieces[b"v"]) - - server_key = self._hmac(self._salted_password, b"Server Key") - server_signature = self._hmac(server_key, self._auth_message) - - if server_signature == verifier: - self.state = SCRAMState.Success - return True - else: - self.state = SCRAMState.VerifyFailed - return False diff --git a/modules/server_time.py b/modules/server_time.py deleted file mode 100644 index e363b341..00000000 --- a/modules/server_time.py +++ /dev/null @@ -1,17 +0,0 @@ -from src import ModuleManager, utils - -CAP = utils.irc.Capability("server-time") -TAG = utils.irc.MessageTag("time") - -class Module(ModuleManager.BaseModule): - @utils.hook("received.cap.ls") - @utils.hook("received.cap.new") - def on_cap(self, event): - if CAP.available(event["capabilities"]): - return CAP.copy() - - @utils.hook("raw.received") - def raw_recv(self, event): - server_time = TAG.get_value(event["line"].tags) - if not server_time == None: - event["server"].set_setting("last-server-time", server_time) diff --git a/modules/sts.py b/modules/sts.py deleted file mode 100644 index 09ecf523..00000000 --- a/modules/sts.py +++ /dev/null @@ -1,69 +0,0 @@ -import time -from src import ModuleManager, utils - -CAP = utils.irc.Capability("sts", "draft/sts") - -class Module(ModuleManager.BaseModule): - def _get_policy(self, server): - return server.get_setting("sts-policy", None) - def _set_policy(self, server, policy): - self.log.info("Setting STS policy for '%s': %s", [str(server), policy]) - server.set_setting("sts-policy", policy) - def _remove_policy(self, server): - server.del_setting("sts-policy") - - def set_policy(self, server, port, duration): - expiration = None - self._set_policy(server, { - "port": port, - "from": time.time(), - "duration": duration}) - def change_duration(self, server, info): - duration = int(info["duration"]) - if duration == 0: - self._remove_policy(server) - else: - port = server.connection_params.port - if "port" in info: - port = int(info["port"]) - self.set_policy(server, port, duration) - - @utils.hook("received.cap.ls") - def on_cap_ls(self, event): - sts = CAP.available(event["capabilities"]) - if sts: - info = utils.parse.keyvalue(event["capabilities"][sts], - delimiter=",") - if not event["server"].connection_params.tls: - self.set_policy(event["server"], int(info["port"]), None) - event["server"].disconnect() - self.bot.reconnect(event["server"].id, - event["server"].connection_params) - else: - self.change_duration(event["server"], info) - - @utils.hook("received.cap.new") - def on_cap_new(self, event): - sts = self._get_sts(event["capabilities"]) - if sts and event["server"].connection_params.tls: - info = utils.parse.keyvalue(sts, delimiter=",") - self.change_duration(event["server"], info) - - @utils.hook("new.server") - def new_server(self, event): - sts_policy = self._get_policy(event["server"]) - if sts_policy: - if not event["server"].connection_params.tls: - if not sts_policy["duration"] or time.time() <= ( - sts_policy["from"]+sts_policy["duration"]): - self.log.info("Applying STS policy for '%s'", - [str(event["server"])]) - event["server"].connection_params.tls = True - event["server"].connection_params.port = sts_policy["port"] - - @utils.hook("server.disconnect") - def on_disconnect(self, event): - sts_policy = self._get_policy(event["server"]) - if sts_policy and sts_policy["duration"]: - sts_policy["from"] = time.time() - self._set_policy(event["server"], sts_policy) -- cgit v1.3.1-10-gc9f91