[+] code refactoring & compatibility — aiming Debian's version of Gajim [+] correctly resending messages after session open [+] I love you ♥master
@ -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 |
@ -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 |
@ -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) |