Răsfoiți Sursa

Refactor project into a better structure.

parazyd 4 ani în urmă
părinte
comite
9bbbaa583d
10 a modificat fișierele cu 690 adăugiri și 548 ștergeri
  1. 1 0
      .gitignore
  2. 10 4
      README.md
  3. 0 544
      mallumo.py
  4. 32 0
      mallumo/__init__.py
  5. 181 0
      mallumo/commands.py
  6. 61 0
      mallumo/config.py
  7. 37 0
      mallumo/globalvars.py
  8. 229 0
      mallumo/messages.py
  9. 42 0
      mallumo/statusbar.py
  10. 97 0
      mallumo/utils.py

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+*.pyc

+ 10 - 4
README.md

@@ -19,8 +19,9 @@ First clone the repo and symlink the python module into your weechat
 autoload directory:
 
 ```shell
+$ cd .local/share/weechat/python/
 $ git clone https://github.com/darkrenaissance/mallumo
-$ ln -s $(realpath mallumo/mallumo.py) ~/.weechat/python/autoload/
+$ ln -s $(realpath mallumo/mallumo/__init__.py) ~/.weechat/python/autoload/mallumo.py
 ```
 
 With this method, you can receive updates seamlessly, just by issuing
@@ -32,12 +33,15 @@ With this method, you can receive updates seamlessly, just by issuing
 In weechat:
 
 ```
-/mallumo help
+/help mallumo
 
 [python/mallumo]  /mallumo  gen
                             kex
                             reset
                             status
+                            enable
+                            disable
+
 
 E2E encryption for private IRC messages
 
@@ -47,10 +51,12 @@ Add an E2E item to the status bar by adding '[mallumo]' to the config setting
 weechat.bar.status.items. This will show if your current chat is encrypted.
 
 Usage:
-/mallumo gen          [ generate a keypair ]
+/mallumo gen          [ generate a new keypair ]
 /mallumo kex          [ initiate an e2e encrypted session ]
 /mallumo reset [-f]   [ unset the public key associated to the current buffer ]
-/mallumo status       [ show session status ]
+/mallumo status       [ show and possibly initialize a session ]
+/mallumo disable      [ disable encryption for current buffer ]
+/mallumo enable       [ enable encryption for current buffer ]
 ```
 
 Before exiting, do run `/save` in order to make sure keep any pubkeys

+ 0 - 544
mallumo.py

@@ -1,544 +0,0 @@
-"""E2E encryption in weechat"""
-from base64 import b64encode, b64decode
-import shlex
-import traceback
-
-from nacl.public import PrivateKey, PublicKey, Box
-import weechat as wc
-
-SCRIPT_NAME = "mallumo"
-SCRIPT_AUTHOR = "Ivan Jelincic <parazyd@dyne.org>"
-SCRIPT_LICENSE = "GPL3"
-SCRIPT_VERSION = "0.1"
-SCRIPT_DESC = "E2E encryption for private IRC messages"
-SCRIPT_HELP = f"""{SCRIPT_DESC}
-
-Quick start:
-
-Add an E2E item to the status bar by adding '[mallumo]' to the config setting
-weechat.bar.status.items. This will show if your current chat is encrypted.
-
-Usage:
-/{SCRIPT_NAME} gen          [ generate a keypair ]
-/{SCRIPT_NAME} kex          [ initiate an e2e encrypted session ]
-/{SCRIPT_NAME} reset [-f]   [ unset the public key associated to the current buffer ]
-/{SCRIPT_NAME} status       [ show session status ]
-"""
-
-# Here we'll keep all the NaCl boxes in memory for quick access.
-SODIUM_BOXES = {}
-
-# These are our weechat configuration sections
-CONFIG_SECTIONS = {}
-
-# Configuration file
-CONFIG_FILE = None
-
-# These bytes can't be in a protocol message
-IRC_SANITIZE_TABLE = dict((ord(char), None) for char in "\r\n\x00")
-
-
-def prnt(buf, msg):
-    """Print a message to a given buffer"""
-    wc.prnt(buf, f"{SCRIPT_NAME}: {msg}")
-
-
-def eprnt(buf, msg):
-    """Print an error message to a given buffer"""
-    wc.prnt(buf, f"{wc.prefix('error')}{SCRIPT_NAME}: {msg}")
-
-
-def config_prefix(option):
-    """Set up a prefix for config lookup"""
-    return f"{SCRIPT_NAME}.{option}"
-
-
-def config_get_prefixed(option):
-    """Get a config value prepended with our prefix"""
-    return wc.config_get(config_prefix(option))
-
-
-def config_string(option):
-    """Get a string config value prepended with our prefix"""
-    return wc.config_string(config_get_prefixed(option))
-
-
-def debug(msg):
-    """Debugging facility"""
-    debug_option = config_get_prefixed("general.debug")
-    if not wc.config_boolean(debug_option):
-        return
-
-    debug_buffer = wc.buffer_search("python", f"{SCRIPT_NAME} debug")
-    if not debug_buffer:
-        debug_buffer = wc.buffer_new(f"{SCRIPT_NAME} debug", "", "", "", "")
-        wc.buffer_set(debug_buffer, "title", f"{SCRIPT_NAME} debug")
-        wc.buffer_set(debug_buffer, "localvar_set_no_log", "1")
-
-    prnt(debug_buffer, f"debug:\t{msg}")
-
-
-def buffer_is_private(buf):
-    """Check whether given buffer is a private chat"""
-    return wc.buffer_get_string(buf, "localvar_type") == "private"
-
-
-def irc_user(nick, server):
-    """Format an identifier given nickname and server"""
-    return f"{nick.lower()}@{server}"
-
-
-def e2e_statusbar_cb(data, item, window):
-    """Callback for statusbar item changes"""
-    if window:
-        buf = wc.window_get_pointer(window, "buffer")
-    else:
-        # If the bar item is in a root bar that is not in a window, window
-        # will be empty.
-        buf = wc.current_buffer()
-
-    if not buffer_is_private(buf):
-        return ""
-
-    peer = irc_user(
-        wc.buffer_get_string(buf, "localvar_channel"),
-        wc.buffer_get_string(buf, "localvar_server"),
-    )
-
-    bar_parts = []
-
-    box = SODIUM_BOXES.get(peer)
-
-    if box:
-        bar_parts.append("".join(
-            [wc.color("green"), "SEC",
-             wc.color("default")]))
-    else:
-        bar_parts.append("".join(
-            [wc.color("lightred"), "!SEC",
-             wc.color("default")]))
-
-    result = "".join(bar_parts)
-    if result:
-        result = f"{wc.color('default')}E2E:{result}"
-
-    if box:
-        wc.buffer_set(buf, "localvar_set_e2e_encrypted", "true")
-    else:
-        wc.buffer_set(buf, "localvar_set_e2e_encrypted", "false")
-
-    return result
-
-
-def isupport_value(server, feature):
-    """Check server supports features"""
-    args = f"{server},{feature}"
-    return wc.info_get("irc_server_isupport_value", args)
-
-
-def is_a_channel(channel, server):
-    """Check if we're in a channel"""
-    prefixes = \
-        tuple(isupport_value(server, "CHANTYPES")) + \
-        tuple(isupport_value(server, "STATUSMSG"))
-
-    if not prefixes:
-        prefixes = ("#", "&", "+", "!", "@")
-
-    return channel.startswith(prefixes)
-
-
-class PrivmsgParseException(Exception):
-    """Pass exception if we fail to parse a privmsg"""
-
-
-def parse_privmsg(message, server):
-    """Parse a privmsg"""
-    wc_result = wc.info_get_hashtable("irc_message_parse",
-                                      dict(message=message))
-
-    if wc_result["command"].upper() == "PRIVMSG":
-        target, text = wc_result["arguments"].split(" ", 1)
-        if text.startswith(":"):
-            text = text[1:]
-
-        result = {
-            "from": wc_result["host"],
-            "to": target,
-            "text": text,
-        }
-
-        if wc_result["host"]:
-            result["from_nick"] = wc_result["nick"]
-        else:
-            result["from_nick"] = ""
-
-        if is_a_channel(target, server):
-            result["to_channel"] = target
-            result["to_nick"] = None
-        else:
-            result["to_channel"] = None
-            result["to_nick"] = target
-
-        return result
-
-    raise PrivmsgParseException(message)
-
-
-def command(buf, command_str):
-    """Wrapper around weechat.command"""
-    debug(command_str)
-    wc.command(buf, command_str)
-
-
-def irc_sanitize(msg):
-    """Sanitize IRC input"""
-    return str(msg).translate(IRC_SANITIZE_TABLE)
-
-
-def privmsg(server, nick, message):
-    """Send privmsgs"""
-    for line in message.splitlines():
-        srv = irc_sanitize(server)
-        nik = irc_sanitize(nick)
-        lin = irc_sanitize(line)
-        command("", f"/quote -server {srv} PRIVMSG {nik} :{lin}")
-
-
-def msg_is_kex(msg):
-    """Check if message is for key exchange"""
-    return (msg.startswith("?e2e_kexreq:") or msg.startswith("?e2e_kexrep:")
-            ) and msg.endswith("?") and len(msg) == 57
-
-
-def message_in_cb(data, modifier, modifier_data, string):
-    """Incoming messages callback"""
-    debug(("message_in_cb", data, modifier, modifier_data, string))
-
-    parsed = parse_privmsg(string, modifier_data)
-    debug(("parsed message", parsed))
-
-    # If we're in a channel, do nothing more
-    if parsed["to_channel"]:
-        return string
-
-    server = modifier_data
-
-    # Here we implement commands that might come to us.
-    msg = parsed["text"]
-
-    # Key exchange request/reply
-    if msg_is_kex(msg):
-        encoded_pubkey = msg[12:-1]
-        # Try parsing the public key
-        try:
-            their_pubkey = PublicKey(b64decode(encoded_pubkey))
-        except:
-            # Just do nothing
-            return string
-
-        # At this point we got a valid pubkey. Let's write it down, in case
-        # we didn't have it already.
-        nick = irc_user(parsed["from_nick"], server)
-        if not wc.config_is_set_plugin(f"pubkey_{nick}"):
-            wc.config_set_plugin(f"pubkey_{nick}", encoded_pubkey)
-
-        # If we didn't set up our keypair, we'll also stay silent.
-        encoded_secret = config_string("general.secret")
-        if encoded_secret == "":
-            eprnt("", "You have not created an e2e keypair. Try /mallumo gen")
-            return string
-
-        our_secret = PrivateKey(b64decode(encoded_secret))
-        our_pubkey = b64encode(our_secret.public_key.encode()).decode()
-
-        # Otherwise, reply with our pubkey to complete the key exchange.
-        if msg.startswith("?e2e_kexreq:"):
-            privmsg(server, parsed["from_nick"], f"?e2e_kexrep:{our_pubkey}?")
-
-        # And finally, set up a Box.
-        SODIUM_BOXES[nick] = Box(our_secret, their_pubkey)
-
-        # Make it green!
-        wc.bar_item_update(SCRIPT_NAME)
-        return string
-
-    if msg.startswith("?e2e_msg:") and msg.endswith("?"):
-        # An encrypted message, let's try to decrypt it.
-        encoded_text = msg[9:-1]
-
-        # Do we have a box?
-        nick = irc_user(parsed["from_nick"], server)
-        box = SODIUM_BOXES.get(nick)
-        if not box:
-            if not wc.config_is_set_plugin(f"pubkey_{nick}"):
-                eprnt("", f"{nick} tried to send you an encrypted message")
-                eprnt("", "But we could not find their public key.")
-                eprnt("", "Try to do key exchange first with /mallumo kex")
-                return string
-
-            encoded_pubkey = wc.config_get_plugin(f"pubkey_{nick}")
-            their_pubkey = PublicKey(b64decode(encoded_pubkey))
-
-            encoded_secret = config_string("general.secret")
-            if encoded_secret == "":
-                eprnt("", f"{nick} tried to send you an encrypted message")
-                eprnt("", "But we don't have a secret key set up!")
-                eprnt("", "You have to set up a secret key with /mallumo gen")
-                return string
-
-            our_secret = PrivateKey(b64decode(encoded_secret))
-            SODIUM_BOXES[nick] = Box(our_secret, their_pubkey)
-            box = SODIUM_BOXES.get(nick)
-
-        # Try to decrypt the message
-        try:
-            plaintext = box.decrypt(b64decode(encoded_text))
-        except:
-            eprnt("", f"Failed decrypting message from {nick}")
-            return string
-
-        return string.replace(msg, plaintext.decode())
-
-    nick = irc_user(parsed["from_nick"], server)
-    if SODIUM_BOXES.get(nick):
-        # Prepend a warning if we have an initialized box, but got an
-        # unencrypted message.
-        return string.replace(msg, f"[!SEC] {msg}")
-
-    return string
-
-
-def message_out_cb(data, modifier, modifier_data, string):
-    """Outgoing messages callback"""
-    result = ""
-
-    # If any exception is raised in this function, weechat will not send
-    # the outgoing message, which could be something that the user intended
-    # to be encrypted. This paranoid exception handling ensures that the
-    # system fails closed and not open.
-    try:
-        debug(("message_out_cb", data, modifier, modifier_data, string))
-
-        parsed = parse_privmsg(string, modifier_data)
-        debug(("parsed_message", parsed))
-
-        if parsed["to_channel"]:
-            return string
-
-        # Try encrypting the message
-        server = modifier_data
-
-        # Do we have a box?
-        nick = irc_user(parsed["to"], server)
-        box = SODIUM_BOXES.get(nick)
-        if not box:
-            if not wc.config_is_set_plugin(f"pubkey_{nick}"):
-                eprnt("", f"{nick} tried to send you an encrypted message")
-                eprnt("", "But we could not find their public key.")
-                eprnt("", "Try to do key exchange first with /mallumo kex")
-                return string
-
-            encoded_pubkey = wc.config_get_plugin(f"pubkey_{nick}")
-            their_pubkey = PublicKey(b64decode(encoded_pubkey))
-
-            encoded_secret = config_string("general.secret")
-            if encoded_secret == "":
-                eprnt("", f"{nick} tried to send you an encrypted message")
-                eprnt("", "But we don't have a secret key set up!")
-                eprnt("", "You have to set up a secret key with /mallumo gen")
-                return string
-
-            our_secret = PrivateKey(b64decode(encoded_secret))
-            SODIUM_BOXES[nick] = Box(our_secret, their_pubkey)
-            box = SODIUM_BOXES.get(nick)
-
-        # In case we're replying to kex
-        if parsed["text"].startswith("?e2e_kexrep:"):
-            return string
-
-        encrypted = box.encrypt(parsed["text"].encode())
-        encrypted_encoded = b64encode(encrypted).decode()
-        privmsg(server, parsed["to"], f"?e2e_msg:{encrypted_encoded}?")
-
-    except:
-        try:
-            eprnt("", traceback.format_exc())
-        except:
-            pass
-
-    return result
-
-
-def command_cb(data, buf, args):
-    """mallumo commands"""
-    result = wc.WEECHAT_RC_ERROR
-
-    if not buffer_is_private(buf):
-        eprnt(buf, "These commands can only be ran in a private buffer")
-        return result
-
-    arg_parts = shlex.split(args)
-
-    if arg_parts[0] == "gen":
-        prnt(buf, "========================================================")
-        prnt(buf, "Generating a keypair...")
-        secret = PrivateKey.generate()
-        public = secret.public_key
-        secret_e = b64encode(secret.encode()).decode()
-        public_e = b64encode(public.encode()).decode()
-        prnt(buf, f"Secret: {secret_e}")
-        prnt(buf, f"Public: {public_e}")
-        prnt(buf, "")
-        prnt(buf, "Set this secret key with the following command:")
-        prnt(buf, f'/set {SCRIPT_NAME}.general.secret "{secret_e}"')
-        prnt(buf, "========================================================")
-        return wc.WEECHAT_RC_OK
-
-    if arg_parts[0] == "kex":
-        encoded_secret = config_string("general.secret")
-        if encoded_secret == "":
-            eprnt(buf, "You do not have a keypair set up.")
-            eprnt(buf, f'Run "/{SCRIPT_NAME} gen" to create one')
-            return result
-
-        nick, server = (wc.buffer_get_string(buf, "localvar_channel"),
-                        wc.buffer_get_string(buf, "localvar_server"))
-        user = irc_user(nick, server)
-
-        encoded_pubkey = wc.config_get_plugin(f"pubkey_{user}")
-        if encoded_pubkey != "":
-            eprnt(buf, "This user already has a key set up.")
-            eprnt(buf, "If you want to change it, you have to unset it first:")
-            eprnt(buf, f"/{SCRIPT_NAME} reset")
-            return result
-
-        secret = PrivateKey(b64decode(encoded_secret))
-        public = b64encode(secret.public_key.encode()).decode()
-
-        privmsg(server, nick, f"?e2e_kexreq:{public}?")
-        return wc.WEECHAT_RC_OK
-
-    if arg_parts[0] == "reset":
-        if len(arg_parts) < 2 or arg_parts[1] != "-f":
-            eprnt(buf, f"Use `/{SCRIPT_NAME} reset -f` to actually do this.")
-            return result
-
-        nick = irc_user(wc.buffer_get_string(buf, "localvar_channel"),
-                        wc.buffer_get_string(buf, "localvar_server"))
-
-        command("", f"/unset plugins.var.python.{SCRIPT_NAME}.pubkey_{nick}")
-        prnt(buf, f"Public key for {nick} has been unset.")
-
-        SODIUM_BOXES.pop(nick)
-        prnt(buf, f"Box for {nick} has been burned.")
-
-        return wc.WEECHAT_RC_OK
-
-    if arg_parts[0] == "status":
-        nick = irc_user(wc.buffer_get_string(buf, "localvar_channel"),
-                        wc.buffer_get_string(buf, "localvar_server"))
-
-        status = {
-            "initialized": False,
-            "has_pubkey": False,
-            "has_secret": False,
-        }
-
-        encoded_secret = config_string("general.secret")
-        if encoded_secret != "":
-            status["has_secret"] = True
-
-        encoded_pubkey = wc.config_get_plugin(f"pubkey_{nick}")
-        if encoded_pubkey != "":
-            status["has_pubkey"] = True
-
-        box = SODIUM_BOXES.get(nick)
-        if box:
-            status["initialized"] = True
-
-        prnt(buf, "====================================================")
-        prnt(buf, "E2E Status:")
-        if status["initialized"]:
-            prnt(buf, f"{wc.color('green')}Initialized{wc.color('default')}")
-            prnt(buf, f"Box secret: {b64encode(box.shared_key()).decode()}")
-        else:
-            eprnt(buf, f"{wc.color('red')}Uninitialized{wc.color('default')}")
-            prnt(buf, "Box secret: Not initialized")
-
-        if status["has_pubkey"]:
-            prnt(buf, f"{nick} pubkey: {encoded_pubkey}")
-        else:
-            eprnt(buf, f"{nick} pubkey: Not found")
-
-        if status["has_secret"]:
-            secret = PrivateKey(b64decode(encoded_secret))
-            pubkey = b64encode(secret.public_key.encode()).decode()
-            prnt(buf, f"own pubkey: {pubkey}")
-        else:
-            eprnt(buf, "own pubkey: Not initialized")
-
-        prnt(buf, "====================================================")
-
-        if not status["initialized"] and (status["has_pubkey"] and \
-            status["has_secret"]):
-            # However we have enough things to initiate the encryption.
-            pubkey = PublicKey(b64decode(encoded_pubkey))
-            box = Box(secret, pubkey)
-            SODIUM_BOXES[nick] = box
-            prnt(buf, "We managed to instantiate a box now...")
-            prnt(buf, "Further messages should be e2e encrypted")
-            wc.bar_item_update(SCRIPT_NAME)
-
-    return result
-
-
-def free_all_config():
-    """Free config items that were set"""
-    for section in CONFIG_SECTIONS.values():
-        wc.config_section_free_options(section)
-        wc.config_section_free(section)
-
-    wc.config_free(CONFIG_FILE)
-
-
-def shutdown():
-    """Teardown"""
-    wc.config_write(CONFIG_FILE)
-    free_all_config()
-    wc.bar_item_remove(E2E_STATUSBAR)
-    return wc.WEECHAT_RC_OK
-
-
-if wc.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE,
-               SCRIPT_DESC, "shutdown", ""):
-    prnt("", "Plugin registered successfully!")
-
-    CONFIG_FILE = wc.config_new(SCRIPT_NAME, "", "")
-
-    CONFIG_SECTIONS["general"] = wc.config_new_section(CONFIG_FILE, "general",
-                                                       0, 0, "", "", "", "",
-                                                       "", "", "", "", "", "")
-
-    for opt, typ, description, default in [
-        ("debug", "boolean", "script debugging", "off"),
-        ("secret", "string", "secret key (base64)", ""),
-    ]:
-        wc.config_new_option(CONFIG_FILE, CONFIG_SECTIONS["general"], opt, typ,
-                             description, "", 0, 0, default, default, 0, "",
-                             "", "", "", "", "")
-
-    wc.config_read(CONFIG_FILE)
-
-    wc.hook_modifier("irc_in_privmsg", "message_in_cb", "")
-    wc.hook_modifier("irc_out_privmsg", "message_out_cb", "")
-
-    wc.hook_command(SCRIPT_NAME, SCRIPT_HELP, "gen ||"
-                    "kex ||"
-                    "reset ||"
-                    "status ||", "", "", "command_cb", "")
-
-    E2E_STATUSBAR = wc.bar_item_new(SCRIPT_NAME, "e2e_statusbar_cb", "")
-    wc.bar_item_update(SCRIPT_NAME)
-
-    prnt("", "Plugin initialized successfully!")

+ 32 - 0
mallumo/__init__.py

@@ -0,0 +1,32 @@
+"""E2E encryption in weechat"""
+from mallumo.globalvars import *
+from mallumo.config import init_config, shutdown
+from mallumo.utils import iprnt
+from mallumo.commands import command_cb
+from mallumo.messages import message_in_cb, message_out_cb
+from mallumo.statusbar import e2e_statusbar_cb
+
+try:
+    import weechat as wc
+
+    if wc.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE,
+                   SCRIPT_DESC, "shutdown", ""):
+        init_config()
+
+        iprnt("", "Installing PRIVMSG hooks")
+        wc.hook_modifier("irc_in_privmsg", "message_in_cb", "")
+        wc.hook_modifier("irc_out_privmsg", "message_out_cb", "")
+
+        iprnt("", "Installing command hooks")
+        wc.hook_command(
+            SCRIPT_NAME, SCRIPT_HELP, "gen ||"
+            "kex ||"
+            "reset ||"
+            "status ||"
+            "enable ||"
+            "disable ||", "", "", "command_cb", "")
+
+        iprnt("", "Plugin initialized successfully")
+
+except ModuleNotFoundError:
+    pass

+ 181 - 0
mallumo/commands.py

@@ -0,0 +1,181 @@
+"""Mallumo commands"""
+import shlex
+from base64 import b64encode, b64decode
+
+from nacl.public import PrivateKey, PublicKey, Box
+import weechat as wc
+
+from mallumo.globalvars import SCRIPT_NAME, SODIUM_BOXES
+from mallumo.utils import (buffer_is_private, prnt, iprnt, eprnt, irc_user,
+                           config_string, privmsg, command)
+
+
+def generate_keypair(buf):
+    """Generate a new random keypair and print info to the given buffer"""
+    prnt(buf, "===========================================================")
+    iprnt(buf, "Generating a keypair...")
+
+    secret = PrivateKey.generate()
+    public = secret.public_key
+
+    e_public = b64encode(public.encode()).decode()
+    e_secret = b64encode(secret.encode()).decode()
+
+    iprnt(buf, f"Public key: {e_public}")
+    iprnt(buf, f"Secret key: {e_secret}")
+    iprnt(buf, "")
+    iprnt(buf, "Set this secret key with the following command:")
+    iprnt(buf, f'/set {SCRIPT_NAME}.general.secret "{e_secret}"')
+    prnt(buf, "===========================================================")
+
+
+def initiate_key_exchange(buf):
+    """Initiate key exchange with the other side of the private buffer"""
+    encoded_secret = config_string("general.secret")
+    if encoded_secret == "":
+        eprnt(buf, "You do not have a keypair set up.")
+        eprnt(buf, f'Run "/{SCRIPT_NAME} gen" to create one')
+        return wc.WEECHAT_RC_ERROR
+
+    nick, server = (wc.buffer_get_string(buf, "localvar_channel"),
+                    wc.buffer_get_string(buf, "localvar_server"))
+
+    ident = irc_user(nick, server)
+
+    encoded_pubkey = wc.config_get_plugin(f"pubkey_{ident}")
+    if encoded_pubkey != "":
+        eprnt(buf, "We seem to already know about a key from this user.")
+        eprnt(buf, "If you want to change it, you have to unset it first:")
+        eprnt(buf, f"/{SCRIPT_NAME} reset")
+        return wc.WEECHAT_RC_ERROR
+
+    our_secret = PrivateKey(b64decode(encoded_secret))
+    our_public = b64encode(our_secret.public_key.encode()).decode()
+
+    privmsg(server, nick, f"?e2e_kexreq:{our_public}?")
+    return wc.WEECHAT_RC_OK
+
+
+def reset_session(buf):
+    """Reset an e2e session with the other side of the private buffer"""
+    ident = irc_user(wc.buffer_get_string(buf, "localvar_channel"),
+                     wc.buffer_get_string(buf, "localvar_server"))
+
+    command("", f"/unset plugins.var.python.{SCRIPT_NAME}.pubkey_{ident}")
+    iprnt(buf, f"Public key for {ident} has been unset.")
+
+    SODIUM_BOXES.pop(ident)
+    iprnt(buf, f"Box for {ident} has been burned.")
+
+
+def session_status(buf):
+    """Query the session status of the current private buffer and potentially
+    initialize a dormant session if we know their key"""
+    ident = irc_user(wc.buffer_get_string(buf, "localvar_channel"),
+                     wc.buffer_get_string(buf, "localvar_server"))
+
+    encoded_secret = config_string("general.secret")
+    has_secret = encoded_secret != ""
+
+    encoded_pubkey = wc.config_get_plugin(f"pubkey_{ident}")
+    has_pubkey = encoded_pubkey != ""
+
+    box = SODIUM_BOXES.get(ident)
+    is_initialized = box is not None
+
+    prnt(buf, "===========================================================")
+    if is_initialized:
+        init_status = f"{wc.color('green')}initialized{wc.color('default')}"
+        box_secret = f"{b64encode(box.shared_key()).decode()}"
+    else:
+        init_status = f"{wc.color('red')}uninitialized{wc.color('default')}"
+        box_secret = f"{wc.color('orange')}not found{wc.color('default')}"
+
+    if has_pubkey:
+        known_pub = f"{encoded_pubkey}"
+    else:
+        known_pub = f"{wc.color('orange')}not found{wc.color('default')}"
+
+    if has_secret:
+        _secret = PrivateKey(b64decode(encoded_secret))
+        our_pub = b64encode(_secret.public_key.encode()).decode()
+    else:
+        our_pub = f"{wc.color('lightred')}not set up{wc.color('default')}"
+
+    iprnt(buf, f"E2E Status: {init_status}")
+    iprnt(buf, f"Box secret: {box_secret}")
+    iprnt(buf, f"Known pubkey: {known_pub}")
+    iprnt(buf, f"Our pubkey: {our_pub}")
+    prnt(buf, "===========================================================")
+
+    # Check if we have enough data to initialize a session
+    if not is_initialized and (has_pubkey and has_secret):
+        iprnt(buf, "We have enough data to initialize a session...")
+        pubkey = PublicKey(b64decode(encoded_pubkey))
+        box = Box(_secret, pubkey)
+        SODIUM_BOXES[ident] = box
+        iprnt(buf, "We managed to instantiate a box now.")
+        iprnt(buf, "Further messages should be e2e encrypted")
+        wc.bar_item_update(SCRIPT_NAME)
+
+
+def enable_encryption(buf):
+    """Enable encryption for current buffer"""
+    ident = irc_user(wc.buffer_get_string(buf, "localvar_channel"),
+                     wc.buffer_get_string(buf, "localvar_server"))
+
+    command("", f"/unset plugins.var.python.{SCRIPT_NAME}.disable_{ident}")
+    iprnt(buf, "Encryption has been enabled for this buffer")
+    iprnt(buf, f"Run /{SCRIPT_NAME} status to see the state")
+
+
+def disable_encryption(buf):
+    """Disable encryption for current buffer"""
+    ident = irc_user(wc.buffer_get_string(buf, "localvar_channel"),
+                     wc.buffer_get_string(buf, "localvar_server"))
+
+    wc.config_set_plugin(f"disable_{ident}", "1")
+    iprnt(buf, "Encryption has been disbaled for this buffer. Sad.")
+    iprnt(buf, f"Run /{SCRIPT_NAME} enable to bring it back")
+
+
+def command_cb(data, buf, args):
+    """Central callback for mallumo commands"""
+    arg_parts = shlex.split(args)
+    if len(arg_parts) == 0:
+        return wc.WEECHAT_RC_ERROR
+
+    if arg_parts[0] == "gen":
+        generate_keypair(buf)
+        return wc.WEECHAT_RC_OK
+
+    # The following commands are only supposed to run in private buffers, so
+    # we check and enforce this:
+    if not buffer_is_private(buf):
+        eprnt(buf, "This command can only be ran in a private buffer")
+        return wc.WEECHAT_RC_ERROR
+
+    if arg_parts[0] == "kex":
+        return initiate_key_exchange(buf)
+
+    if arg_parts[0] == "reset":
+        if len(arg_parts) < 2 or arg_parts[1] != "-f":
+            eprnt(buf, f'Use "/{SCRIPT_NAME} reset -f" to actually do this.')
+            return wc.WEECHAT_RC_ERROR
+
+        reset_session(buf)
+        return wc.WEECHAT_RC_OK
+
+    if arg_parts[0] == "status":
+        session_status(buf)
+        return wc.WEECHAT_RC_OK
+
+    if arg_parts[0] == "enable":
+        enable_encryption(buf)
+        return wc.WEECHAT_RC_OK
+
+    if arg_parts[0] == "disable":
+        disable_encryption(buf)
+        return wc.WEECHAT_RC_OK
+
+    return wc.WEECHAT_RC_ERROR

+ 61 - 0
mallumo/config.py

@@ -0,0 +1,61 @@
+"""Configuration utilities"""
+import weechat as wc
+
+from mallumo.globalvars import (CONFIG_SECTIONS, CONFIG_FILE, E2E_STATUSBAR,
+                                SCRIPT_NAME)
+from mallumo.statusbar import e2e_statusbar_cb
+from mallumo.utils import iprnt
+
+
+def free_all_config():
+    """Free config items that were set"""
+    for section in CONFIG_SECTIONS.values():
+        wc.config_section_free_options(section)
+        wc.config_section_free(section)
+
+    wc.config_free(CONFIG_FILE)
+
+
+def shutdown():
+    """Teardown when unloading plugin"""
+    wc.config_write(CONFIG_FILE)
+    free_all_config()
+    wc.bar_item_remove(E2E_STATUSBAR)
+    return wc.WEECHAT_RC_OK
+
+
+def new_section(name):
+    """Install a new config section"""
+    return wc.config_new_section(CONFIG_FILE, name, 0, 0, "", "", "", "", "",
+                                 "", "", "", "", "")
+
+
+def new_option(section, option):
+    """Install a new option in a config section"""
+    wc.config_new_option(CONFIG_FILE, CONFIG_SECTIONS[section], option[0],
+                         option[1], option[2], "", 0, 0, option[3], option[3],
+                         0, "", "", "", "", "", "")
+
+
+def init_config():
+    """Initialize plugin configuration upon loading module"""
+    iprnt("", "Initializing configuration")
+
+    global CONFIG_FILE
+    CONFIG_FILE = wc.config_new(SCRIPT_NAME, "", "")
+    CONFIG_SECTIONS["general"] = new_section("general")
+
+    opts = [
+        ("debug", "boolean", "script debugging", "off"),
+        ("secret", "string", "secret key (base64)", ""),
+    ]
+
+    for opt in opts:
+        new_option("general", opt)
+
+    wc.config_read(CONFIG_FILE)
+
+    iprnt("", "Initializing statusbar")
+    global E2E_STATUSBAR
+    E2E_STATUSBAR = wc.bar_item_new(SCRIPT_NAME, "e2e_statusbar_cb", "")
+    wc.bar_item_update(SCRIPT_NAME)

+ 37 - 0
mallumo/globalvars.py

@@ -0,0 +1,37 @@
+"""Global variables to pass around"""
+
+SCRIPT_NAME = "mallumo"
+SCRIPT_AUTHOR = "Ivan Jelincic <parazyd@dyne.org>"
+SCRIPT_LICENSE = "GPL3"
+SCRIPT_VERSION = "0.1"
+SCRIPT_DESC = "E2E encryption for private IRC messages"
+SCRIPT_HELP = f"""{SCRIPT_DESC}
+
+Quick start:
+
+Add an E2E item to the status bar by adding '[mallumo]' to the config setting
+weechat.bar.status.items. This will show if your current chat is encrypted.
+
+Usage:
+/{SCRIPT_NAME} gen          [ generate a new keypair ]
+/{SCRIPT_NAME} kex          [ initiate an e2e encrypted session ]
+/{SCRIPT_NAME} reset [-f]   [ unset the public key associated to the current buffer ]
+/{SCRIPT_NAME} status       [ show and possibly initialize a session ]
+/{SCRIPT_NAME} disable      [ disable encryption for current buffer ]
+/{SCRIPT_NAME} enable       [ enable encryption for current buffer ]
+"""
+
+# Here we'll keep all the NaCl boxes in memory for quick access.
+SODIUM_BOXES = {}
+
+# These are our weechat configuration sections
+CONFIG_SECTIONS = {}
+
+# Configuration file
+CONFIG_FILE = None
+
+# These bytes can't be in a protocol message
+IRC_SANITIZE_TABLE = dict((ord(char), None) for char in "\r\n\x00")
+
+# Statusbar item
+E2E_STATUSBAR = None

+ 229 - 0
mallumo/messages.py

@@ -0,0 +1,229 @@
+"""Message callbacks and cryptography"""
+import json
+import traceback
+from base64 import b64decode, b64encode
+
+import weechat as wc
+from nacl.public import PublicKey, PrivateKey, Box
+
+from mallumo.globalvars import SCRIPT_NAME, SODIUM_BOXES
+from mallumo.utils import (debug, is_a_channel, eprnt, privmsg, irc_user,
+                           config_string)
+
+
+def msg_is_kex(msg):
+    """Check if message is related to key exchange"""
+    return (msg.startswith("?e2e_kexreq:") or msg.startswith("?e2e_kexrep:")
+            ) and msg.endswith("?") and len(msg) == 57
+
+
+class PrivmsgParseException(Exception):
+    """Pass exception if we fail to parse a privmsg"""
+
+
+def parse_privmsg(message, server):
+    """Parse a privmsg"""
+    wc_result = wc.info_get_hashtable("irc_message_parse",
+                                      dict(message=message))
+
+    if wc_result["command"].upper() == "PRIVMSG":
+        target, text = wc_result["arguments"].split(" ", 1)
+        if text.startswith(":"):
+            text = text[1:]
+
+        result = {
+            "from": wc_result["host"],
+            "to": target,
+            "text": text,
+        }
+
+        if wc_result["host"]:
+            result["from_nick"] = wc_result["nick"]
+        else:
+            result["from_nick"] = ""
+
+        if is_a_channel(target, server):
+            result["to_channel"] = target
+            result["to_nick"] = None
+        else:
+            result["to_channel"] = None
+            result["to_nick"] = target
+
+        return result
+
+    raise PrivmsgParseException(message)
+
+
+def message_in_cb(data, modifier, modifier_data, string):
+    """Incoming messages callback"""
+    debug(("message_in_cb()", data, modifier, modifier_data, string))
+
+    parsed = parse_privmsg(string, modifier_data)
+    debug(json.dumps(parsed))
+
+    # If we're in a channel, do nothing more
+    if parsed["to_channel"]:
+        return string
+
+    # Now we check if we're getting keys or encrypted messages
+    server = modifier_data
+    ident = irc_user(parsed["from_nick"], server)
+    msg = parsed["text"]
+
+    # Key exchange request/reply
+    if msg_is_kex(msg):
+        encoded_pubkey = msg[msg.find(":") + 1:-1]
+        # Try parsing the public key
+        try:
+            recv_pubkey = PublicKey(b64decode(encoded_pubkey))
+        except:
+            # Just do nothing
+            return string
+
+        # At this point we got a valid pubkey. Let's write it down, in case
+        # we didn't have it already.
+        if not wc.config_is_set_plugin(f"pubkey_{ident}"):
+            wc.config_set_plugin(f"pubkey_{ident}", encoded_pubkey)
+        else:
+            eprnt("", f"{ident} gave us a key different than the one we know")
+            eprnt("", "It's possible to reset the session if needed using:")
+            eprnt("", f"/{SCRIPT_NAME} reset")
+
+        # If we didn't set up our keypair, we stay silent.
+        encoded_secret = config_string("general.secret")
+        if encoded_secret == "":
+            eprnt("", f"{ident} requested key exchange, but we have no keys")
+            eprnt("", f"Use /{SCRIPT_NAME} gen to create one")
+            return string
+
+        our_secret = PrivateKey(b64decode(encoded_secret))
+        our_pubkey = b64encode(our_secret.public_key.encode()).decode()
+
+        # Otherwise, we reply with our pubkey to complete the key exchange.
+        if msg.startswith("?e2e_kexreq:"):
+            privmsg(server, parsed["from_nick"], f"?e2e_kexrep:{our_pubkey}?")
+
+        # And finally, set up a Box.
+        SODIUM_BOXES[ident] = Box(our_secret, recv_pubkey)
+
+        # Make it green!
+        wc.bar_item_update(SCRIPT_NAME)
+        return string
+
+    # An encrypted message, let's try to decrypt it.
+    if msg.startswith("?e2e_msg:") and msg.endswith("?"):
+        encoded_text = msg[msg.find(":") + 1:-1]
+
+        # Do we have a box?
+        box = SODIUM_BOXES.get(ident)
+
+        if not box:
+            if not wc.config_is_set_plugin(f"pubkey_{ident}"):
+                eprnt("", f"{ident} tried to send an encrypted message.")
+                eprnt("", "But we could not find their public key.")
+                eprnt("", f"Try to exchange keys with /${SCRIPT_NAME} kex")
+                return string
+
+            # Instantiate a box since we seem to have a public key match
+            encoded_pubkey = wc.config_get_plugin(f"pubkey_{ident}")
+            their_pubkey = PublicKey(b64decode(encoded_pubkey))
+
+            encoded_secret = config_string("general.secret")
+            if encoded_secret == "":
+                eprnt("", f"{ident} tried to send an encrypted message.")
+                eprnt("", "But we don't have a secret key set up.")
+                eprnt("", f"Create one with /${SCRIPT_NAME} gen")
+                eprnt("", f"And then exchange keys with /${SCRIPT_NAME} kex")
+                return string
+
+            our_secret = PrivateKey(b64decode(encoded_secret))
+            SODIUM_BOXES[ident] = Box(our_secret, their_pubkey)
+            box = SODIUM_BOXES.get(ident)
+
+        # Try to decrypt the message
+        try:
+            plaintext = box.decrypt(b64decode(encoded_text))
+        except:
+            eprnt("", f"Failed decrypting message from {ident}")
+            return string
+
+        return string.replace(msg, plaintext.decode())
+
+    if SODIUM_BOXES.get(ident):
+        # Prepend a warning if we have an instantiated box, but got an
+        # unencrypted message.
+        return string.replace(msg, f"[Unencrypted message] {msg}")
+
+    # If nothing happened, just pass the message through
+    return string
+
+
+def message_out_cb(data, modifier, modifier_data, string):
+    """Outgoing messages callback"""
+    result = ""
+
+    # If any exception is raised in this function, weechat will not send
+    # the outgoing message, which could be somethign that the user intended
+    # to be encrypted. This paranoid exception handling ensures that the
+    # system fails closed and not open.
+    try:
+        debug(("message_out_cb", data, modifier, modifier_data, string))
+
+        parsed = parse_privmsg(string, modifier_data)
+        debug(json.dumps(parsed))
+
+        # Skip processing messages to public channels
+        if parsed["to_channel"]:
+            return string
+
+        server = modifier_data
+        ident = irc_user(parsed["to"], server)
+
+        # And also if we've forcefully disabled encryption. Sad.
+        if wc.config_is_set_plugin(f"disable_{ident}"):
+            return string
+
+        # In case we're replying to kex
+        if msg_is_kex(parsed["text"]):
+            return string
+
+        buf = wc.current_buffer()
+
+        # Try encrypting the message
+        box = SODIUM_BOXES.get(ident)
+        if not box:
+            if not wc.config_is_set_plugin(f"pubkey_{ident}"):
+                eprnt(buf, "We tried to send an encrypted message")
+                eprnt(buf, "But we could not find the recipient's pubkey")
+                eprnt(buf, f"Try to exchange keys with /{SCRIPT_NAME} kex")
+                eprnt(buf, f"Or disable encryption: /{SCRIPT_NAME} disable")
+                raise Exception("No recipient pubkey")
+
+            encoded_pubkey = wc.config_get_plugin(f"pubkey_{ident}")
+            their_pubkey = PublicKey(b64decode(encoded_pubkey))
+
+            encoded_secret = config_string("general.secret")
+            if encoded_secret == "":
+                eprnt(buf, "We tried to send an encrypted message")
+                eprnt(buf, "But we don't have a secret key set up.")
+                eprnt(buf, f"Create one with /${SCRIPT_NAME} gen")
+                eprnt(buf, f"And then exchange keys with /${SCRIPT_NAME} kex")
+                eprnt(buf, f"Or disable encryption: /{SCRIPT_NAME} disable")
+                raise Exception("No secret key")
+
+            our_secret = PrivateKey(b64decode(encoded_secret))
+            SODIUM_BOXES[ident] = Box(our_secret, their_pubkey)
+            box = SODIUM_BOXES.get(ident)
+
+        encrypted = box.encrypt(parsed["text"].encode())
+        encrypted_encoded = b64encode(encrypted).decode()
+        privmsg(server, parsed["to"], f"?e2e_msg:{encrypted_encoded}?")
+
+    except:
+        try:
+            eprnt("", traceback.format_exc())
+            eprnt(buf, "Failed sending message. See core buffer for trace.")
+        except:
+            pass
+
+    return result

+ 42 - 0
mallumo/statusbar.py

@@ -0,0 +1,42 @@
+"""Statusbar functionality"""
+import weechat as wc
+
+from mallumo.globalvars import SODIUM_BOXES
+from mallumo.utils import buffer_is_private, irc_user
+
+
+def e2e_statusbar_cb(data, item, window):
+    """Callback for statusbar item changes"""
+    if window:
+        buf = wc.window_get_pointer(window, "buffer")
+    else:
+        # If the bar item is in a root bar that is not in a window, window
+        # will be empty.
+        buf = wc.current_buffer()
+
+    if not buffer_is_private(buf):
+        return ""
+
+    nick = irc_user(
+        wc.buffer_get_string(buf, "localvar_channel"),
+        wc.buffer_get_string(buf, "localvar_server"),
+    )
+
+    sbar = []
+
+    box = SODIUM_BOXES.get(nick)
+    if box:
+        sbar.append("".join([wc.color("green"), "SEC", wc.color("default")]))
+    else:
+        sbar.append("".join([wc.color("red"), "!SEC", wc.color("default")]))
+
+    result = "".join(sbar)
+    if result:
+        result = f"{wc.color('default')}E2E:{result}"
+
+    if box:
+        wc.buffer_set(buf, "localvar_set_e2e_encrypted", "true")
+    else:
+        wc.buffer_set(buf, "localvar_set_e2e_encrypted", "false")
+
+    return result

+ 97 - 0
mallumo/utils.py

@@ -0,0 +1,97 @@
+"""Weechat tility functions"""
+import weechat as wc
+
+from mallumo.globalvars import SCRIPT_NAME, IRC_SANITIZE_TABLE
+
+
+def prnt(buf, msg):
+    """Print a message to the given buffer"""
+    wc.prnt(buf, f"{SCRIPT_NAME}: {msg}")
+
+
+def iprnt(buf, msg):
+    """Print an informational message to the given buffer"""
+    wc.prnt(buf, f"{wc.prefix('network')}{SCRIPT_NAME}: {msg}")
+
+
+def eprnt(buf, msg):
+    """Print an error message to the given buffer"""
+    wc.prnt(buf, f"{wc.prefix('error')}{SCRIPT_NAME}: {msg}")
+
+
+def config_prefix(option):
+    """Set up a prefix for config lookup"""
+    return f"{SCRIPT_NAME}.{option}"
+
+
+def config_get_prefixed(option):
+    """Get a config value prepended with our prefix"""
+    return wc.config_get(config_prefix(option))
+
+
+def config_string(option):
+    """Get a string config value prepended with our prefix"""
+    return wc.config_string(config_get_prefixed(option))
+
+
+def debug(msg):
+    """Debugging facility"""
+    debug_option = config_get_prefixed("general.debug")
+    if not wc.config_boolean(debug_option):
+        return
+
+    debug_buffer = wc.buffer_search("python", f"{SCRIPT_NAME} debug")
+    if not debug_buffer:
+        debug_buffer = wc.buffer_new(f"{SCRIPT_NAME} debug", "", "", "", "")
+        wc.buffer_set(debug_buffer, "title", f"{SCRIPT_NAME} debug")
+        wc.buffer_set(debug_buffer, "localvar_set_no_log", "1")
+
+    prnt(debug_buffer, f"debug: {msg}")
+
+
+def buffer_is_private(buf):
+    """Check whether given buffer is a private chat"""
+    return wc.buffer_get_string(buf, "localvar_type") == "private"
+
+
+def irc_user(nick, server):
+    """Format an internal identifier given a nickname and server"""
+    return f"{nick.lower()}@{server}"
+
+
+def isupport_value(server, feature):
+    """Check server supports features"""
+    args = f"{server},{feature}"
+    return wc.info_get("irc_server_isupport_value", args)
+
+
+def is_a_channel(channel, server):
+    """Check if we're in a channel"""
+    prefixes = \
+        tuple(isupport_value(server, "CHANTYPES")) + \
+        tuple(isupport_value(server, "STATUSMSG"))
+
+    if not prefixes:
+        prefixes = ("#", "&", "+", "!", "@")
+
+    return channel.startswith(prefixes)
+
+
+def command(buf, command_str):
+    """Wrapper around weechat.command"""
+    debug(command_str)
+    wc.command(buf, command_str)
+
+
+def irc_sanitize(msg):
+    """Sanitize IRC input"""
+    return str(msg).translate(IRC_SANITIZE_TABLE)
+
+
+def privmsg(server, nick, message):
+    """Send privmsgs"""
+    for line in message.splitlines():
+        srv = irc_sanitize(server)
+        nik = irc_sanitize(nick)
+        lin = irc_sanitize(line)
+        command("", f"/quote -server {srv} PRIVMSG {nik} :{lin}")