Release 0.2
[+] code refactoring & compatibility — aiming Debian's version of Gajim [+] correctly resending messages after session open [+] I love you ♥
This commit is contained in:
parent
8803d248dc
commit
aa98fd901c
|
@ -45,3 +45,4 @@ class Keystore:
|
|||
|
||||
def close(self):
|
||||
self._db.close()
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
185
modules/otr.py
185
modules/otr.py
|
@ -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
otr.py
Normal file
172
otr.py
Normal 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)
|
70
plugin.py
70
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)
|
||||
|
|
Loading…
Reference in a new issue