Browse Source

Release 0.2

[+] code refactoring & compatibility — aiming Debian's version of Gajim
[+] correctly resending messages after session open
[+] I love you ♥
master
annelin 5 months ago
parent
commit
aa98fd901c
5 changed files with 209 additions and 225 deletions
  1. 1
    0
      keystore.py
  2. 3
    3
      manifest.ini
  3. 0
    185
      modules/otr.py
  4. 172
    0
      otr.py
  5. 33
    37
      plugin.py

+ 1
- 0
keystore.py View File

@@ -45,3 +45,4 @@ class Keystore:

def close(self):
self._db.close()


+ 3
- 3
manifest.ini View File

@@ -1,9 +1,9 @@
[info]
name: otrplugin
short_name: otrplugin
version: 0.1
version: 0.2
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
min_gajim_version: 1.1
max_gajim_version: 1.3

+ 0
- 185
modules/otr.py View File

@@ -1,185 +0,0 @@
## 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

+ 172
- 0
otr.py View File

@@ -0,0 +1,172 @@
## 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/>.

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)

+ 33
- 37
plugin.py View File

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

Loading…
Cancel
Save