Browse Source

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)
master
annelin 5 months ago
commit
ec43021650
5 changed files with 309 additions and 0 deletions
  1. 1
    0
      __init__.py
  2. 47
    0
      keystore.py
  3. 9
    0
      manifest.ini
  4. 185
    0
      modules/otr.py
  5. 67
    0
      plugin.py

+ 1
- 0
__init__.py View File

@@ -0,0 +1 @@
from .plugin import OTRPlugin

+ 47
- 0
keystore.py View File

@@ -0,0 +1,47 @@
# Copyright (C) 2019 Philipp Hörist <philipp AT hoerist.com>
# Copyright (C) 2019 Pavel R. <pd at narayana dot im>

# 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 <http://www.gnu.org/licenses/>.

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()

+ 9
- 0
manifest.ini View File

@@ -0,0 +1,9 @@
[info]
name: otrplugin
short_name: otrplugin
version: 0.1
description: Off-the-Record encryption
authors: Pavel R <pd@narayana.im>
homepage: https://dev.narayana.im/gajim-otrplugin
min_gajim_version: 1.1.91
max_gajim_version: 1.2.90

+ 185
- 0
modules/otr.py View File

@@ -0,0 +1,185 @@
## Copyright (C) 2008-2012 Kjell Braden <afflux@pentabarf.de>
## Copyright (C) 2019 Pavel R. <pd at narayana dot im>
#

# 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 <http://www.gnu.org/licenses/>.

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 <peer>
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 <peer>
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

+ 67
- 0
plugin.py View File

@@ -0,0 +1,67 @@
# Copyright (C) 2019 Philipp Hörist <philipp AT hoerist.com>
# Copyright (C) 2019 Pavel R. <pd at narayana dot im>

# 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 <http://www.gnu.org/licenses/>.

# 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)

Loading…
Cancel
Save