commit ec430216502f0211c57c33249cf0601e404d2ba5 Author: annelin Date: Sat Jun 22 14:54:00 2019 +0300 Initial commit Implemented: - OTR encryption - Store OTR keys and known fingerprints to Gajim data directory in SQLite format - Handling OTR errors Todo: - Fingerprints verification - SMP protocol - Presence handling (e.g. close OTR channel when contacts goes offline) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..7d727a7 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from .plugin import OTRPlugin diff --git a/keystore.py b/keystore.py new file mode 100644 index 0000000..35debd5 --- /dev/null +++ b/keystore.py @@ -0,0 +1,47 @@ +# Copyright (C) 2019 Philipp Hörist +# 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 sqlite3 +from collections import namedtuple + +class Keystore: + + __TABLE_LAYOUT__ = ''' + CREATE TABLE IF NOT EXISTS keystore (jid TEXT, privatekey TEXT, fingerprint TEXT, trust INTEGER, timestamp INTEGER, comment TEXT, UNIQUE(privatekey)); CREATE UNIQUE INDEX IF NOT EXISTS jid_fingerprint ON keystore (jid, fingerprint); + ''' + + def __init__(self, db): + self._db = sqlite3.connect(db, isolation_level=None) + self._db.row_factory = lambda cur,row : namedtuple("Row", [col[0] for col in cur.description])(*row) + self._db.execute("PRAGMA synchronous=FULL;") + self._db.executescript(self.__TABLE_LAYOUT__) + + def load(self, item = {'fingerprint IS NOT NULL; --': None}): + sql = "SELECT * FROM keystore WHERE " + " AND ".join(["%s = '%s'" % (str(key), str(value)) for key,value in item.items()]) + if next(iter(item.values())): return self._db.execute(sql).fetchone() # return fetchone() if `item` arg is set + return self._db.execute(sql).fetchall() or () # else return fetchall() or empty iterator + + def save(self, item): + sql = "REPLACE INTO keystore(%s) VALUES(%s)" % (",".join(item.keys()), ",".join(["'%s'" % x for x in item.values()]) ) + return self._db.execute(sql) + + def forgot(self, item): + sql = "DELETE FROM keystore WHERE " + " AND ".join(["%s='%s'" % (str(key),str(value)) for key,value in item.items()]) + return self._db.execute(sql) + + def close(self): + self._db.close() diff --git a/manifest.ini b/manifest.ini new file mode 100644 index 0000000..b624411 --- /dev/null +++ b/manifest.ini @@ -0,0 +1,9 @@ +[info] +name: otrplugin +short_name: otrplugin +version: 0.1 +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 diff --git a/modules/otr.py b/modules/otr.py new file mode 100644 index 0000000..7c552a7 --- /dev/null +++ b/modules/otr.py @@ -0,0 +1,185 @@ +## 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/plugin.py b/plugin.py new file mode 100644 index 0000000..4a344cd --- /dev/null +++ b/plugin.py @@ -0,0 +1,67 @@ +# Copyright (C) 2019 Philipp Hörist +# 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 . + +# TODO: OTR state notifications +# TODO: Fingerprints authentication GUI +# TODO: SMP authentication GUI + +import logging +import os +import nbxmpp +from gajim.common import app +from gajim.common.connection_handlers_events import MessageOutgoingEvent +from gajim.plugins import GajimPlugin +from gajim.plugins.plugins_i18n import _ +from otrplugin.modules import otr + +log = logging.getLogger('gajim.p.otrplugin') + +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.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 + + @staticmethod + def activate_encryption(chat_control): + 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): + callback(file)