diff --git a/keystore.py b/keystore.py index 35debd5..f00419e 100644 --- a/keystore.py +++ b/keystore.py @@ -45,3 +45,4 @@ class Keystore: def close(self): self._db.close() + diff --git a/manifest.ini b/manifest.ini index b624411..fac7e4c 100644 --- a/manifest.ini +++ b/manifest.ini @@ -1,9 +1,9 @@ [info] name: otrplugin short_name: otrplugin -version: 0.1 +version: 0.2 description: Off-the-Record encryption authors: Pavel R homepage: https://dev.narayana.im/gajim-otrplugin -min_gajim_version: 1.1.91 -max_gajim_version: 1.2.90 +min_gajim_version: 1.1 +max_gajim_version: 1.3 diff --git a/modules/otr.py b/modules/otr.py deleted file mode 100644 index 7c552a7..0000000 --- a/modules/otr.py +++ /dev/null @@ -1,185 +0,0 @@ -## Copyright (C) 2008-2012 Kjell Braden -## Copyright (C) 2019 Pavel R. -# - -# This file is part of Gajim OTR Plugin. -# -# Gajim OTR Plugin is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published -# by the Free Software Foundation; version 3 only. -# -# This software is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You can always obtain full license text at . - -name = 'OTR' -zeroconf = None -ENCRYPTION_NAME = 'OTR' -ERROR = None -OTR = { - 'PROTOCOL': 'XMPP', - 'MMS': 1000, - 'POLICY': { - 'REQUIRE_ENCRYPTION': True, - 'ALLOW_V1': False, - 'ALLOW_V2': True, - 'SEND_TAG': True, - 'WHITESPACE_START_AKE': True, - 'ERROR_START_AKE': True, - } -} - -import os -from collections import namedtuple -from nbxmpp.structs import StanzaHandler -from nbxmpp.protocol import Message, JID -from nbxmpp.const import MessageType -from gajim.common import app -from gajim.common import configpaths -from gajim.common.nec import NetworkEvent -from gajim.common.const import EncryptionData -from gajim.common.modules.base import BaseModule -from gajim.plugins.plugins_i18n import _ -from otrplugin.keystore import Keystore -try: - import potr as otr -except: - ERROR = 'Unable to import python-otr (python3-potr)' - - -# otr channel prototype # -class OTRChannel(otr.context.Context): - global OTR - - # init OTR channel here - def __init__(self, account, peer): - super(OTRChannel, self).__init__(account, peer) - self.peer = peer - self.resend = [] - self.defaultQuery = account.getDefaultQueryMessage(self.getPolicy) - self.trustName = peer.getBare() - self.println = lambda line: account.otrmodule.get_control(peer).conv_textview.print_conversation_line(line, 'status', '', None) - - # send message to jabber network - def inject(self, msg, appdata=None): - stanza = Message(to=self.peer, body=msg.decode(), typ='chat') - stanza.setThread(appdata.get('thread')) if appdata and appdata.get('thread') else None - self.user.otrmodule.stream.send_stanza(stanza) - - # we can catch state change here - def setState(self, newstate): - state, self.state = self.state, newstate - if self.getCurrentTrust() is None: # new fingerprint - self.setCurrentTrust(0) - self.println("OTR: new fingerprint received [%s]" % self.getCurrentKey()) - if newstate == otr.context.STATE_ENCRYPTED and state != newstate: # channel established - self.println("OTR: %s encrypted conversation started [%s]" % (self.getCurrentTrust() and 'trusted' or '**untrusted**', self.getCurrentKey()) ) - elif newstate == otr.context.STATE_FINISHED and state != newstate: # channel closed - self.println("OTR: encrypted conversation closed") - - def getPolicy(self, key): - return OTR['POLICY'][key] if key in OTR['POLICY'] else None - - -# otr account prototype -class OTRAccount(otr.context.Account): - global OTR - contextclass = OTRChannel - - # init otrfor gajim acct - def __init__(self, otrmodule): - super(OTRAccount, self).__init__(otrmodule.jid, OTR['PROTOCOL'], OTR['MMS']) - self.jid = otrmodule.jid - self.keystore = otrmodule.keystore - self.otrmodule = otrmodule - self.loadTrusts() - - def loadPrivkey(self): - my = self.keystore.load({'jid': str(self.jid)}) - return otr.crypt.PK.parsePrivateKey(bytes.fromhex(my.privatekey))[0] if my and my.privatekey else None - - def savePrivkey(self): - return self.keystore.save({'jid': self.jid, 'privatekey': self.getPrivkey().serializePrivateKey().hex()}) - - def loadTrusts(self): - for c in self.keystore.load(): self.setTrust(c.jid, c.fingerprint, c.trust) - - def saveTrusts(self): - for jid, keys in self.trusts.items(): - for fingerprint, trust in keys.items(): self.keystore.save({'jid': jid, 'fingerprint': fingerprint, 'trust': trust}) - - -# Module name -class OTRModule(BaseModule): - - def __init__(self, con): - BaseModule.__init__(self, con) - self.handlers = [ - StanzaHandler(name='message', callback=self.receive_message, priority=9), - ] - self.jid = self._con.get_own_jid() # JID - self.stream = self._con # XMPP stream - self.keystore = Keystore(os.path.join(configpaths.get('MY_DATA'), 'otr_' + self.jid.getBare())) # Key storage - self.otr = OTRAccount(self) # OTR object - self.channels = {} - self.controls = {} - - # get chat control for - def get_control(self, peer): - return self.controls.setdefault(peer, app.interface.msg_win_mgr.get_control(peer.getBare(), self._account)) - - # get otr channel for - def get_channel(self, peer): - self.channels.get(peer) and self.channels[peer].state == otr.context.STATE_FINISHED and self.channels.pop(peer).disconnect() - return self.channels.setdefault(peer, self.otr.getContext(peer)) - - # receive and decrypt message - def receive_message(self, con, stanza, message): - if message.type != MessageType.CHAT or not stanza.getBody() or stanza.getBody().encode().find(otr.proto.OTRTAG) == -1: return # it is not OTR message - if message.is_mam_message: return stanza.setBody('') # it is OTR message from archive, we can not decrypt it - self._log.debug('got otr message: %s' % stanza) # everything is fine, we can try to decrypt it - try: - channel = self.get_channel(stanza.getFrom()) - text, tlvs = channel.receiveMessage(stanza.getBody().encode(), {'thread': stanza.getThread()}) - stanza.setBody(text and text.decode() or '') - message.encrypted = EncryptionData({'name':'OTR'}) - except otr.context.UnencryptedMessage: - channel.println("OTR: received plain message [%s]" % stanza.getBody()) - self._log.error('** got plain text over encrypted channel ** %s' % stanza.getBody()) - except otr.context.ErrorReceived as e: - channel.println("OTR: received error [%s]" % e) - self._log.error('** otr error ** %s' % e) - except otr.crypt.InvalidParameterError: - channel.println("OTR: received unreadable message (session expired?)") - self._log.error('** unreadable message **') - except otr.context.NotEncryptedError: - channel.println("OTR: session lost") - self._log.error('** otr channel lost **') - channel.resend and channel.state == otr.context.STATE_ENCRYPTED and channel.sendMessage(otr.context.FRAGMENT_SEND_ALL, **(channel.resend.pop())) # resend any spooled messages - return - - # encrypt and send message - def encrypt_message(self, obj, callback): - self._log.warning('sending otr message: %s ' % obj.msg_iq) - try: - peer = obj.msg_iq.getTo() - channel = self.get_channel(peer) - session = obj.session or self.stream.make_new_session(peer) - message = obj.message.encode() - encrypted = channel.sendMessage(otr.context.FRAGMENT_SEND_ALL_BUT_LAST, message, appdata = {'thread': session.thread_id}) or b'' - encrypted == channel.defaultQuery and channel.resend.append({'msg': message, 'appdata': {'thread': session.thread_id}}) - except otr.context.NotEncryptedError: - self._log.warning('message was not sent.') - return - - obj.encrypted = 'OTR' - obj.additional_data['encrypted'] = {'name': 'OTR'} - obj.msg_iq.setBody(encrypted.decode()) - callback(obj) - - -def get_instance(*args, **kwargs): - return OTRModule(*args, **kwargs), name diff --git a/otr.py b/otr.py new file mode 100644 index 0000000..1a7b71f --- /dev/null +++ b/otr.py @@ -0,0 +1,172 @@ +## Copyright (C) 2008-2012 Kjell Braden +## Copyright (C) 2019 Pavel R. +# + +# This file is part of Gajim OTR Plugin. +# +# Gajim OTR Plugin is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published +# by the Free Software Foundation; version 3 only. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You can always obtain full license text at . + +import os +import time +import logging +from potr import context, crypt +from gajim.common import const, app, helpers, configpaths +from gajim.session import ChatControlSession +from nbxmpp.protocol import Message, JID +from otrplugin.keystore import Keystore + +# OTR channel class +class Channel(context.Context): + def __init__(self, account, peer): + super(Channel, self).__init__(account, peer) + self.getPolicy = OTR.DEFAULT_POLICY.get # default OTR flags + self.trustName = peer.getStripped() # peer name + self.stream = account.stream # XMPP stream + self.ctl = account.getControl(peer.getStripped()) # chat window control + self.resend = [] + + def println(self, line, kind='status', **kwargs): + try: self.ctl.conv_textview.print_conversation_line(line, kind=kind, tim=None, jid=None, name='', **kwargs) + except TypeError: self.ctl.conv_textview.print_conversation_line(line, kind=kind, tim=None, name='', **kwargs) # gajim git fix + return line + + def inject(self, msg, appdata={}): + thread = appdata.get('thread', ChatControlSession.generate_thread_id(None)) + stanza = Message(to=self.peer, body=msg.decode(), typ='chat') + stanza.setThread(thread) + self.stream.send_stanza(stanza) + + def setState(self, newstate): + state, self.state = self.state, newstate + if self.getCurrentTrust() is None: + self.println("OTR: new fingerprint received [%s]" % self.getCurrentKey()) + self.setCurrentTrust(0) + if newstate == context.STATE_ENCRYPTED and state != newstate: + self.println("OTR: %s conversation started [%s]" % (self.getCurrentTrust() and 'trusted' or '**untrusted**', self.getCurrentKey()) ) + elif newstate == context.STATE_FINISHED and state != newstate: # channel closed + self.println("OTR: conversation closed.") + + +# OTR class +class OTR(context.Account): + contextclass = Channel + + # OTR const + ENCRYPTION_NAME = 'OTR' + ENCRYPTION_DATA = helpers.AdditionalDataDict({'encrypted':{'name': ENCRYPTION_NAME}}) + PROTOCOL = 'XMPP' + MMS = 1000 + DEFAULT_POLICY = { + 'REQUIRE_ENCRYPTION': True, + 'ALLOW_V1': False, + 'ALLOW_V2': True, + 'SEND_TAG': True, + 'WHITESPACE_START_AKE': True, + 'ERROR_START_AKE': True, + } + SESSION_START = '?OTRv2?\nI would like to start ' \ + 'an Off-the-Record private conversation. However, you ' \ + 'do not have a plugin to support that.\nSee '\ + 'https://otr.cypherpunks.ca/ for more information.' + + def __init__(self, account, logger = None): + super(OTR, self).__init__(account, OTR.PROTOCOL, OTR.MMS) + self.log = logger + self.ctxs, self.ctls = {}, {} + self.account = account + self.stream = app.connections[account] + self.jid = self.stream.get_own_jid() + self.keystore = Keystore(os.path.join(configpaths.get('MY_DATA'), 'otr_' + self.jid.getStripped() + '.db')) + self.loadTrusts() + + # overload some default methods # + def getControl(self, peer): + ctrl = self.ctls.setdefault(peer, app.interface.msg_win_mgr.get_control(peer, self.account)) + return ctrl + + def getContext(self, peer): + ctx = self.ctxs.setdefault(peer, Channel(self, peer)) + ctx = ctx.state == context.STATE_FINISHED and self.ctxs.pop(peer).disconnect() or self.ctxs.setdefault(peer, Channel(self, peer)) + return ctx + + def loadPrivkey(self): + my = self.keystore.load({'jid': str(self.jid)}) + return crypt.PK.parsePrivateKey(bytes.fromhex(my.privatekey))[0] if my and my.privatekey else None + + def savePrivkey(self): + return self.keystore.save({'jid': self.jid, 'privatekey': self.getPrivkey().serializePrivateKey().hex()}) + + def loadTrusts(self): + for c in self.keystore.load(): self.setTrust(c.jid, c.fingerprint, c.trust) + + def saveTrusts(self): + for jid, keys in self.trusts.items(): + for fingerprint, trust in keys.items(): self.keystore.save({'jid': jid, 'fingerprint': fingerprint, 'trust': trust}) + + + # decrypt & receive + def _decrypt(self, event, callback): + try: + peer = event.stanza.getFrom() + channel = self.getContext(peer) + text, tlvs = channel.receiveMessage(event.msgtxt.encode(), appdata = {'thread': event.stanza.getThread()}) + text = text and text.decode() or "" + except context.UnencryptedMessage: + self.log.error('** got plain text over encrypted channel ** %s' % stanza.getBody()) + channel.println("OTR: received plain message [%s]" % event.stanza.getBody()) + except context.ErrorReceived as e: + self.log.error('** otr error ** %s' % e) + channel.println("OTR: received error [%s]" % e) + except crypt.InvalidParameterError: + self.log.error('** unreadable message **') + channel.println("OTR: received unreadable message (session expired?)") + except context.NotEncryptedError: + self.log.error('** otr session lost **') + channel.println("OTR: session lost.") + + # resent messages after channel open + if channel.resend and channel.state == context.STATE_ENCRYPTED: + message = channel.resend.pop() + channel.sendMessage(**message) + channel.println(message['msg'].decode(), kind='outgoing', encrypted=self.ENCRYPTION_NAME, additional_data=self.ENCRYPTION_DATA) + + event.xhtml = None + event.msgtxt = text + event.encrypted = self.ENCRYPTION_NAME + event.additional_data = self.ENCRYPTION_DATA + + callback(event) + + + # encrypt & send + def _encrypt(self, event, callback): + try: + peer = event.msg_iq.getTo() + channel = self.getContext(peer) + session = event.session or ChatControlSession(self.stream, peer, None, 'chat') + encrypted = channel.sendMessage(sendPolicy = context.FRAGMENT_SEND_ALL_BUT_LAST, msg = event.message.encode(), appdata = {'thread': session.thread_id}) or b'' + except context.NotEncryptedError: + self.log.error("** unable to encrypt message **") + channel.println('OTR: unable to start conversation') + return + + # resend lost message after session start + if encrypted == OTR.SESSION_START.encode(): + channel.println('OTR: trying to start encrypted conversation') + channel.resend += [{'sendPolicy': context.FRAGMENT_SEND_ALL, 'msg': event.message.encode(), 'appdata': {'thread': session.thread_id}}] + event.message = '' + + event.encrypted = 'OTR' + event.additional_data['encrypted'] = {'name': 'OTR'} + event.msg_iq.setBody(encrypted.decode()) + + callback(event) diff --git a/plugin.py b/plugin.py index 4a344cd..d6d6095 100644 --- a/plugin.py +++ b/plugin.py @@ -18,50 +18,46 @@ # TODO: Fingerprints authentication GUI # TODO: SMP authentication GUI +ERROR = None + import logging -import os -import nbxmpp -from gajim.common import app -from gajim.common.connection_handlers_events import MessageOutgoingEvent +from gajim.common import app from gajim.plugins import GajimPlugin -from gajim.plugins.plugins_i18n import _ -from otrplugin.modules import otr +try: from otrplugin.otr import OTR +except: ERROR = 'Error importing python3-potr module. Make sure it is installed.' -log = logging.getLogger('gajim.p.otrplugin') +log = logging.getLogger('gajim.p.otr') -class OTRPlugin(GajimPlugin): +class OTRPlugin(GajimPlugin): def init(self): - self.description = _('Provides Off-the-Record encryption for tet messages') - self.encryption_name = otr.ENCRYPTION_NAME - self.modules = [otr] + self.encryption_name = OTR.ENCRYPTION_NAME + self.description = 'Provides Off-the-Record encryption' + self.activatable = not ERROR + self.available_text = ERROR + self.sessions = {} + self.session = lambda account: self.sessions.setdefault(account, OTR(account, logger = log)) self.gui_extension_points = { - 'encrypt' + self.encryption_name: (self.encrypt_message, None), - 'encryption_state' + self.encryption_name: (self.encryption_state, None), - } - self.activatable = not otr.ERROR - self.available_text = otr.ERROR - return - - def activate(self): - pass - - def deactivate(self): - pass - + 'encrypt' + self.encryption_name: (self._encrypt_message, None), + 'decrypt': (self._decrypt_message, None), + } + @staticmethod - def activate_encryption(chat_control): + def activate_encryption(ctl): return True - @staticmethod - def encryption_state(chat_control, state): - state['visible'] = True - state['authenticated'] = False - - def encrypt_message(self, conn, event, callback): - if not event.message: return - otr = app.connections[event.account].get_module('OTR') - otr.encrypt_message(event, callback) - - # if not set, gajim will not allow us to send file with OTR encryption enabled - def encrypt_file(self, file, account, callback): + @staticmethod + def encrypt_file(file, account, callback): callback(file) + + def _encrypt_message(self, con, event, callback): + if not event.message or event.type_ != 'chat': return # drop empty or non-chat messages + log.debug('encrypting message: %s' % event) + otr = self.session(event.account) + otr._encrypt(event, callback) + + def _decrypt_message(self, con, event, callback): + if event.name == 'mam-message-received': event.msgtxt = '' # drop mam messages because we cannot decrypt it post-factum + if not event.msgtxt or not event.msgtxt.startswith("?OTR"): return # drop messages without OTR tag + log.debug('received otr message: %s' % event) + otr = self.session(event.account) + otr._decrypt(event, callback)