| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540 |
- """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.")
- 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!")
|