mallumo.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  1. """E2E encryption in weechat"""
  2. from base64 import b64encode, b64decode
  3. import shlex
  4. import traceback
  5. from nacl.public import PrivateKey, PublicKey, Box
  6. import weechat as wc
  7. SCRIPT_NAME = "mallumo"
  8. SCRIPT_AUTHOR = "Ivan Jelincic <parazyd@dyne.org>"
  9. SCRIPT_LICENSE = "GPL3"
  10. SCRIPT_VERSION = "0.1"
  11. SCRIPT_DESC = "E2E encryption for private IRC messages"
  12. SCRIPT_HELP = f"""{SCRIPT_DESC}
  13. Quick start:
  14. Add an E2E item to the status bar by adding '[mallumo]' to the config setting
  15. weechat.bar.status.items. This will show if your current chat is encrypted.
  16. Usage:
  17. /{SCRIPT_NAME} gen [ generate a keypair ]
  18. /{SCRIPT_NAME} kex [ initiate an e2e encrypted session ]
  19. /{SCRIPT_NAME} reset [-f] [ unset the public key associated to the current buffer ]
  20. /{SCRIPT_NAME} status [ show session status ]
  21. """
  22. # Here we'll keep all the NaCl boxes in memory for quick access.
  23. SODIUM_BOXES = {}
  24. # These are our weechat configuration sections
  25. CONFIG_SECTIONS = {}
  26. # Configuration file
  27. CONFIG_FILE = None
  28. # These bytes can't be in a protocol message
  29. IRC_SANITIZE_TABLE = dict((ord(char), None) for char in "\r\n\x00")
  30. def prnt(buf, msg):
  31. """Print a message to a given buffer"""
  32. wc.prnt(buf, f"{SCRIPT_NAME}: {msg}")
  33. def eprnt(buf, msg):
  34. """Print an error message to a given buffer"""
  35. wc.prnt(buf, f"{wc.prefix('error')}{SCRIPT_NAME}: {msg}")
  36. def config_prefix(option):
  37. """Set up a prefix for config lookup"""
  38. return f"{SCRIPT_NAME}.{option}"
  39. def config_get_prefixed(option):
  40. """Get a config value prepended with our prefix"""
  41. return wc.config_get(config_prefix(option))
  42. def config_string(option):
  43. """Get a string config value prepended with our prefix"""
  44. return wc.config_string(config_get_prefixed(option))
  45. def debug(msg):
  46. """Debugging facility"""
  47. debug_option = config_get_prefixed("general.debug")
  48. if not wc.config_boolean(debug_option):
  49. return
  50. debug_buffer = wc.buffer_search("python", f"{SCRIPT_NAME} debug")
  51. if not debug_buffer:
  52. debug_buffer = wc.buffer_new(f"{SCRIPT_NAME} debug", "", "", "", "")
  53. wc.buffer_set(debug_buffer, "title", f"{SCRIPT_NAME} debug")
  54. wc.buffer_set(debug_buffer, "localvar_set_no_log", "1")
  55. prnt(debug_buffer, f"debug:\t{msg}")
  56. def buffer_is_private(buf):
  57. """Check whether given buffer is a private chat"""
  58. return wc.buffer_get_string(buf, "localvar_type") == "private"
  59. def irc_user(nick, server):
  60. """Format an identifier given nickname and server"""
  61. return f"{nick.lower()}@{server}"
  62. def e2e_statusbar_cb(data, item, window):
  63. """Callback for statusbar item changes"""
  64. if window:
  65. buf = wc.window_get_pointer(window, "buffer")
  66. else:
  67. # If the bar item is in a root bar that is not in a window, window
  68. # will be empty.
  69. buf = wc.current_buffer()
  70. if not buffer_is_private(buf):
  71. return ""
  72. peer = irc_user(
  73. wc.buffer_get_string(buf, "localvar_channel"),
  74. wc.buffer_get_string(buf, "localvar_server"),
  75. )
  76. bar_parts = []
  77. box = SODIUM_BOXES.get(peer)
  78. if box:
  79. bar_parts.append("".join(
  80. [wc.color("green"), "SEC",
  81. wc.color("default")]))
  82. else:
  83. bar_parts.append("".join(
  84. [wc.color("lightred"), "!SEC",
  85. wc.color("default")]))
  86. result = "".join(bar_parts)
  87. if result:
  88. result = f"{wc.color('default')}E2E:{result}"
  89. if box:
  90. wc.buffer_set(buf, "localvar_set_e2e_encrypted", "true")
  91. else:
  92. wc.buffer_set(buf, "localvar_set_e2e_encrypted", "false")
  93. return result
  94. def isupport_value(server, feature):
  95. """Check server supports features"""
  96. args = f"{server},{feature}"
  97. return wc.info_get("irc_server_isupport_value", args)
  98. def is_a_channel(channel, server):
  99. """Check if we're in a channel"""
  100. prefixes = \
  101. tuple(isupport_value(server, "CHANTYPES")) + \
  102. tuple(isupport_value(server, "STATUSMSG"))
  103. if not prefixes:
  104. prefixes = ("#", "&", "+", "!", "@")
  105. return channel.startswith(prefixes)
  106. class PrivmsgParseException(Exception):
  107. """Pass exception if we fail to parse a privmsg"""
  108. def parse_privmsg(message, server):
  109. """Parse a privmsg"""
  110. wc_result = wc.info_get_hashtable("irc_message_parse",
  111. dict(message=message))
  112. if wc_result["command"].upper() == "PRIVMSG":
  113. target, text = wc_result["arguments"].split(" ", 1)
  114. if text.startswith(":"):
  115. text = text[1:]
  116. result = {
  117. "from": wc_result["host"],
  118. "to": target,
  119. "text": text,
  120. }
  121. if wc_result["host"]:
  122. result["from_nick"] = wc_result["nick"]
  123. else:
  124. result["from_nick"] = ""
  125. if is_a_channel(target, server):
  126. result["to_channel"] = target
  127. result["to_nick"] = None
  128. else:
  129. result["to_channel"] = None
  130. result["to_nick"] = target
  131. return result
  132. raise PrivmsgParseException(message)
  133. def command(buf, command_str):
  134. """Wrapper around weechat.command"""
  135. debug(command_str)
  136. wc.command(buf, command_str)
  137. def irc_sanitize(msg):
  138. """Sanitize IRC input"""
  139. return str(msg).translate(IRC_SANITIZE_TABLE)
  140. def privmsg(server, nick, message):
  141. """Send privmsgs"""
  142. for line in message.splitlines():
  143. srv = irc_sanitize(server)
  144. nik = irc_sanitize(nick)
  145. lin = irc_sanitize(line)
  146. command("", f"/quote -server {srv} PRIVMSG {nik} :{lin}")
  147. def msg_is_kex(msg):
  148. """Check if message is for key exchange"""
  149. return (msg.startswith("?e2e_kexreq:") or msg.startswith("?e2e_kexrep:")
  150. ) and msg.endswith("?") and len(msg) == 57
  151. def message_in_cb(data, modifier, modifier_data, string):
  152. """Incoming messages callback"""
  153. debug(("message_in_cb", data, modifier, modifier_data, string))
  154. parsed = parse_privmsg(string, modifier_data)
  155. debug(("parsed message", parsed))
  156. # If we're in a channel, do nothing more
  157. if parsed["to_channel"]:
  158. return string
  159. server = modifier_data
  160. # Here we implement commands that might come to us.
  161. msg = parsed["text"]
  162. # Key exchange request/reply
  163. if msg_is_kex(msg):
  164. encoded_pubkey = msg[12:-1]
  165. # Try parsing the public key
  166. try:
  167. their_pubkey = PublicKey(b64decode(encoded_pubkey))
  168. except:
  169. # Just do nothing
  170. return string
  171. # At this point we got a valid pubkey. Let's write it down, in case
  172. # we didn't have it already.
  173. nick = irc_user(parsed["from_nick"], server)
  174. if not wc.config_is_set_plugin(f"pubkey_{nick}"):
  175. wc.config_set_plugin(f"pubkey_{nick}", encoded_pubkey)
  176. # If we didn't set up our keypair, we'll also stay silent.
  177. encoded_secret = config_string("general.secret")
  178. if encoded_secret == "":
  179. eprnt("", "You have not created an e2e keypair. Try /mallumo gen")
  180. return string
  181. our_secret = PrivateKey(b64decode(encoded_secret))
  182. our_pubkey = b64encode(our_secret.public_key.encode()).decode()
  183. # Otherwise, reply with our pubkey to complete the key exchange.
  184. if msg.startswith("?e2e_kexreq:"):
  185. privmsg(server, parsed["from_nick"], f"?e2e_kexrep:{our_pubkey}?")
  186. # And finally, set up a Box.
  187. SODIUM_BOXES[nick] = Box(our_secret, their_pubkey)
  188. # Make it green!
  189. wc.bar_item_update(SCRIPT_NAME)
  190. return string
  191. if msg.startswith("?e2e_msg:") and msg.endswith("?"):
  192. # An encrypted message, let's try to decrypt it.
  193. encoded_text = msg[9:-1]
  194. # Do we have a box?
  195. nick = irc_user(parsed["from_nick"], server)
  196. box = SODIUM_BOXES.get(nick)
  197. if not box:
  198. if not wc.config_is_set_plugin(f"pubkey_{nick}"):
  199. eprnt("", f"{nick} tried to send you an encrypted message")
  200. eprnt("", "But we could not find their public key.")
  201. eprnt("", "Try to do key exchange first with /mallumo kex")
  202. return string
  203. encoded_pubkey = wc.config_get_plugin(f"pubkey_{nick}")
  204. their_pubkey = PublicKey(b64decode(encoded_pubkey))
  205. encoded_secret = config_string("general.secret")
  206. if encoded_secret == "":
  207. eprnt("", f"{nick} tried to send you an encrypted message")
  208. eprnt("", "But we don't have a secret key set up!")
  209. eprnt("", "You have to set up a secret key with /mallumo gen")
  210. return string
  211. our_secret = PrivateKey(b64decode(encoded_secret))
  212. SODIUM_BOXES[nick] = Box(our_secret, their_pubkey)
  213. box = SODIUM_BOXES.get(nick)
  214. # Try to decrypt the message
  215. try:
  216. plaintext = box.decrypt(b64decode(encoded_text))
  217. except:
  218. eprnt("", f"Failed decrypting message from {nick}")
  219. return string
  220. return string.replace(msg, plaintext.decode())
  221. nick = irc_user(parsed["from_nick"], server)
  222. if SODIUM_BOXES.get(nick):
  223. # Prepend a warning if we have an initialized box, but got an
  224. # unencrypted message.
  225. return string.replace(msg, f"[!SEC] {msg}")
  226. return string
  227. def message_out_cb(data, modifier, modifier_data, string):
  228. """Outgoing messages callback"""
  229. result = ""
  230. # If any exception is raised in this function, weechat will not send
  231. # the outgoing message, which could be something that the user intended
  232. # to be encrypted. This paranoid exception handling ensures that the
  233. # system fails closed and not open.
  234. try:
  235. debug(("message_out_cb", data, modifier, modifier_data, string))
  236. parsed = parse_privmsg(string, modifier_data)
  237. debug(("parsed_message", parsed))
  238. if parsed["to_channel"]:
  239. return string
  240. # Try encrypting the message
  241. server = modifier_data
  242. # Do we have a box?
  243. nick = irc_user(parsed["to"], server)
  244. box = SODIUM_BOXES.get(nick)
  245. if not box:
  246. if not wc.config_is_set_plugin(f"pubkey_{nick}"):
  247. eprnt("", f"{nick} tried to send you an encrypted message")
  248. eprnt("", "But we could not find their public key.")
  249. eprnt("", "Try to do key exchange first with /mallumo kex")
  250. return string
  251. encoded_pubkey = wc.config_get_plugin(f"pubkey_{nick}")
  252. their_pubkey = PublicKey(b64decode(encoded_pubkey))
  253. encoded_secret = config_string("general.secret")
  254. if encoded_secret == "":
  255. eprnt("", f"{nick} tried to send you an encrypted message")
  256. eprnt("", "But we don't have a secret key set up!")
  257. eprnt("", "You have to set up a secret key with /mallumo gen")
  258. return string
  259. our_secret = PrivateKey(b64decode(encoded_secret))
  260. SODIUM_BOXES[nick] = Box(our_secret, their_pubkey)
  261. box = SODIUM_BOXES.get(nick)
  262. # In case we're replying to kex
  263. if parsed["text"].startswith("?e2e_kexrep:"):
  264. return string
  265. encrypted = box.encrypt(parsed["text"].encode())
  266. encrypted_encoded = b64encode(encrypted).decode()
  267. privmsg(server, parsed["to"], f"?e2e_msg:{encrypted_encoded}?")
  268. except:
  269. try:
  270. eprnt("", traceback.format_exc())
  271. except:
  272. pass
  273. return result
  274. def command_cb(data, buf, args):
  275. """mallumo commands"""
  276. result = wc.WEECHAT_RC_ERROR
  277. if not buffer_is_private(buf):
  278. eprnt(buf, "These commands can only be ran in a private buffer")
  279. return result
  280. arg_parts = shlex.split(args)
  281. if arg_parts[0] == "gen":
  282. prnt(buf, "========================================================")
  283. prnt(buf, "Generating a keypair...")
  284. secret = PrivateKey.generate()
  285. public = secret.public_key
  286. secret_e = b64encode(secret.encode()).decode()
  287. public_e = b64encode(public.encode()).decode()
  288. prnt(buf, f"Secret: {secret_e}")
  289. prnt(buf, f"Public: {public_e}")
  290. prnt(buf, "")
  291. prnt(buf, "Set this secret key with the following command:")
  292. prnt(buf, f'/set {SCRIPT_NAME}.general.secret "{secret_e}"')
  293. prnt(buf, "========================================================")
  294. return wc.WEECHAT_RC_OK
  295. if arg_parts[0] == "kex":
  296. encoded_secret = config_string("general.secret")
  297. if encoded_secret == "":
  298. eprnt(buf, "You do not have a keypair set up.")
  299. eprnt(buf, f'Run "/{SCRIPT_NAME} gen" to create one')
  300. return result
  301. nick, server = (wc.buffer_get_string(buf, "localvar_channel"),
  302. wc.buffer_get_string(buf, "localvar_server"))
  303. user = irc_user(nick, server)
  304. encoded_pubkey = wc.config_get_plugin(f"pubkey_{user}")
  305. if encoded_pubkey != "":
  306. eprnt(buf, "This user already has a key set up.")
  307. eprnt(buf, "If you want to change it, you have to unset it first:")
  308. eprnt(buf, f"/{SCRIPT_NAME} reset")
  309. return result
  310. secret = PrivateKey(b64decode(encoded_secret))
  311. public = b64encode(secret.public_key.encode()).decode()
  312. privmsg(server, nick, f"?e2e_kexreq:{public}?")
  313. return wc.WEECHAT_RC_OK
  314. if arg_parts[0] == "reset":
  315. if len(arg_parts) < 2 or arg_parts[1] != "-f":
  316. eprnt(buf, f"Use `/{SCRIPT_NAME} reset -f` to actually do this.")
  317. return result
  318. nick = irc_user(wc.buffer_get_string(buf, "localvar_channel"),
  319. wc.buffer_get_string(buf, "localvar_server"))
  320. command("", f"/unset plugins.var.python.{SCRIPT_NAME}.pubkey_{nick}")
  321. prnt(buf, f"Public key for {nick} has been unset.")
  322. return wc.WEECHAT_RC_OK
  323. if arg_parts[0] == "status":
  324. nick = irc_user(wc.buffer_get_string(buf, "localvar_channel"),
  325. wc.buffer_get_string(buf, "localvar_server"))
  326. status = {
  327. "initialized": False,
  328. "has_pubkey": False,
  329. "has_secret": False,
  330. }
  331. encoded_secret = config_string("general.secret")
  332. if encoded_secret != "":
  333. status["has_secret"] = True
  334. encoded_pubkey = wc.config_get_plugin(f"pubkey_{nick}")
  335. if encoded_pubkey != "":
  336. status["has_pubkey"] = True
  337. box = SODIUM_BOXES.get(nick)
  338. if box:
  339. status["initialized"] = True
  340. prnt(buf, "====================================================")
  341. prnt(buf, "E2E Status:")
  342. if status["initialized"]:
  343. prnt(buf, f"{wc.color('green')}Initialized{wc.color('default')}")
  344. prnt(buf, f"Box secret: {b64encode(box.shared_key()).decode()}")
  345. else:
  346. eprnt(buf, f"{wc.color('red')}Uninitialized{wc.color('default')}")
  347. prnt(buf, "Box secret: Not initialized")
  348. if status["has_pubkey"]:
  349. prnt(buf, f"{nick} pubkey: {encoded_pubkey}")
  350. else:
  351. eprnt(buf, f"{nick} pubkey: Not found")
  352. if status["has_secret"]:
  353. secret = PrivateKey(b64decode(encoded_secret))
  354. pubkey = b64encode(secret.public_key.encode()).decode()
  355. prnt(buf, f"own pubkey: {pubkey}")
  356. else:
  357. eprnt(buf, "own pubkey: Not initialized")
  358. prnt(buf, "====================================================")
  359. if not status["initialized"] and (status["has_pubkey"] and \
  360. status["has_secret"]):
  361. # However we have enough things to initiate the encryption.
  362. pubkey = PublicKey(b64decode(encoded_pubkey))
  363. box = Box(secret, pubkey)
  364. SODIUM_BOXES[nick] = box
  365. prnt(buf, "We managed to instantiate a box now...")
  366. prnt(buf, "Further messages should be e2e encrypted")
  367. wc.bar_item_update(SCRIPT_NAME)
  368. return result
  369. def free_all_config():
  370. """Free config items that were set"""
  371. for section in CONFIG_SECTIONS.values():
  372. wc.config_section_free_options(section)
  373. wc.config_section_free(section)
  374. wc.config_free(CONFIG_FILE)
  375. def shutdown():
  376. """Teardown"""
  377. wc.config_write(CONFIG_FILE)
  378. free_all_config()
  379. wc.bar_item_remove(E2E_STATUSBAR)
  380. return wc.WEECHAT_RC_OK
  381. if wc.register(SCRIPT_NAME, SCRIPT_AUTHOR, SCRIPT_VERSION, SCRIPT_LICENSE,
  382. SCRIPT_DESC, "shutdown", ""):
  383. prnt("", "Plugin registered successfully!")
  384. CONFIG_FILE = wc.config_new(SCRIPT_NAME, "", "")
  385. CONFIG_SECTIONS["general"] = wc.config_new_section(CONFIG_FILE, "general",
  386. 0, 0, "", "", "", "",
  387. "", "", "", "", "", "")
  388. for opt, typ, description, default in [
  389. ("debug", "boolean", "script debugging", "off"),
  390. ("secret", "string", "secret key (base64)", ""),
  391. ]:
  392. wc.config_new_option(CONFIG_FILE, CONFIG_SECTIONS["general"], opt, typ,
  393. description, "", 0, 0, default, default, 0, "",
  394. "", "", "", "", "")
  395. wc.config_read(CONFIG_FILE)
  396. wc.hook_modifier("irc_in_privmsg", "message_in_cb", "")
  397. wc.hook_modifier("irc_out_privmsg", "message_out_cb", "")
  398. wc.hook_command(SCRIPT_NAME, SCRIPT_HELP, "gen ||"
  399. "kex ||"
  400. "reset ||"
  401. "status ||", "", "", "command_cb", "")
  402. E2E_STATUSBAR = wc.bar_item_new(SCRIPT_NAME, "e2e_statusbar_cb", "")
  403. wc.bar_item_update(SCRIPT_NAME)
  404. prnt("", "Plugin initialized successfully!")