#!/usr/bin/env python # -*- coding: utf-8 -*- ## otrmodule.py ## ## Copyright 2008-2012 Kjell Braden ## ## This file is part of Gajim. ## ## Gajim 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. ## ## Gajim 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 should have received a copy of the GNU General Public License ## along with Gajim. If not, see . ## ''' Off-The-Record encryption plugin. :author: Kjell self.Braden :since: 2008 :copyright: Copyright 2008-2012 Kjell Braden :license: GPL ''' MINVERSION = (1,0,0,'beta5') MINCRYPTOVERSION = (2,1,0,'final',0) IGNORE = True PASS = False DEFAULTFLAGS = { 'ALLOW_V1':False, 'ALLOW_V2':True, 'REQUIRE_ENCRYPTION':False, 'SEND_TAG':True, 'WHITESPACE_START_AKE':True, 'ERROR_START_AKE':True, } MMS = 1024 PROTOCOL = 'xmpp' MINVERSION_OUTGOING_MSG_STAZA = "0.16.4" enc_tip = 'A private chat session is established to this contact ' \ 'with this fingerprint' unused_tip = 'A private chat session is established to this contact using ' \ 'another fingerprint' ended_tip = 'The private chat session to this contact has ended' inactive_tip = 'Communication to this contact is currently ' \ 'unencrypted' msg_not_send = _('Your message was not send. Either end ' 'your private conversation, or restart it') import logging import nbxmpp import os import pickle import time import sys from pprint import pformat from distutils.version import LooseVersion from common import gajim from common import ged from common.connection_handlers_events import MessageOutgoingEvent from plugins import GajimPlugin from message_control import TYPE_CHAT, MessageControl from plugins.helpers import log_calls, log from plugins.plugin import GajimPluginException import ui sys.path.insert(0, os.path.dirname(ui.__file__)) from HTMLParser import HTMLParser from htmlentitydefs import name2codepoint name2codepoint['apos'] = 0x0027 HAS_CRYPTO = True try: import Crypto if not hasattr(Crypto, 'version_info') \ or Crypto.version_info < MINCRYPTOVERSION: raise ImportError('PyCrypto not found or too old') except ImportError: HAS_CRYPTO = False HAS_POTR = True try: import potr import potr.crypt import potr.context if not hasattr(potr, 'VERSION') or potr.VERSION < MINVERSION: raise ImportError('old / unsupported python-otr version') potrrootlog = logging.getLogger('potr') potrrootlog.handlers = [] potrrootlog.propagate = False gajimrootlog = logging.getLogger('gajim') for h in gajimrootlog.handlers: potrrootlog.addHandler(h) def get_jid_from_fjid(fjid): return gajim.get_room_and_nick_from_fjid(str(fjid))[0] class GajimContext(potr.context.Context): # self.peer is fjid # self.jid does not contain resource __slots__ = ['smpWindow', 'jid'] def __init__(self, account, peer): super(GajimContext, self).__init__(account, peer) self.jid = get_jid_from_fjid(peer) self.trustName = self.jid self.smpWindow = ui.ContactOtrSmpWindow(self) def inject(self, msg, appdata=None): log.debug('inject(appdata=%s)', appdata) msg = unicode(msg) account = self.user.accountname stanza = nbxmpp.Message(to=self.peer, body=msg, typ='chat') if appdata is not None: thread_id = appdata.get('thread', None) if thread_id is not None: stanza.setThread(thread_id) add_message_processing_hints(stanza) gajim.connections[account].connection.send(stanza, now=True) def setState(self, newstate): if self.state == potr.context.STATE_ENCRYPTED: # we were encrypted if newstate == potr.context.STATE_ENCRYPTED: # and are still -> it's just a refresh OtrPlugin.gajim_log( _('Private conversation with %s refreshed.') % self.peer, self.user.accountname, self.peer) elif newstate == potr.context.STATE_FINISHED: # and aren't anymore -> other side disconnected OtrPlugin.gajim_log(_('%s has ended his/her private ' 'conversation with you. You should do the same.') % self.peer, self.user.accountname, self.peer) else: if newstate == potr.context.STATE_ENCRYPTED: # we are now encrypted trust = self.getCurrentTrust() if trust is None: fpr = str(self.getCurrentKey()) OtrPlugin.gajim_log(_('New fingerprint for %(peer)s: %(fpr)s') % {'peer': self.peer, 'fpr': fpr}, self.user.accountname, self.peer) self.setCurrentTrust('') trustStr = 'authenticated' if bool(trust) else '*unauthenticated*' OtrPlugin.gajim_log( _('%(trustStr)s secured OTR conversation with %(peer)s started') % {'trustStr': trustStr, 'peer': self.peer}, self.user.accountname, self.peer) if self.state != potr.context.STATE_PLAINTEXT and \ newstate == potr.context.STATE_PLAINTEXT: # we are now plaintext OtrPlugin.gajim_log( _('Private conversation with %s lost.') % self.peer, self.user.accountname, self.peer) super(GajimContext, self).setState(newstate) OtrPlugin.update_otr(self.peer, self.user.accountname) self.user.plugin.update_context_list() def getPolicy(self, key): ret = self.user.plugin.get_flags(self.user.accountname, self.jid)[key] log.debug('getPolicy(key=%s) = %s', key, ret) return ret class GajimOtrAccount(potr.context.Account): contextclass = GajimContext def __init__(self, plugin, accountname): global PROTOCOL, MMS self.plugin = plugin self.accountname = accountname name = gajim.get_jid_from_account(accountname) super(GajimOtrAccount, self).__init__(name, PROTOCOL, MMS) self.keyFilePath = os.path.join(gajim.gajimpaths.data_root, accountname) def dropPrivkey(self): try: os.remove(self.keyFilePath + '.key3') except IOError, e: if e.errno != 2: log.exception('IOError occurred when removing key file for %s', self.name) self.privkey = None def loadPrivkey(self): try: with open(self.keyFilePath + '.key3', 'rb') as keyFile: return potr.crypt.PK.parsePrivateKey(keyFile.read())[0] except IOError, e: if e.errno != 2: log.exception('IOError occurred when loading key file for %s', self.name) return None def savePrivkey(self): try: with open(self.keyFilePath + '.key3', 'wb') as keyFile: keyFile.write(self.getPrivkey().serializePrivateKey()) except IOError, e: log.exception('IOError occurred when loading key file for %s', self.name) def loadTrusts(self, newCtxCb=None): ''' load the fingerprint trustdb ''' # it has the same format as libotr, therefore the # redundant account / proto field try: with open(self.keyFilePath + '.fpr', 'r') as fprFile: for line in fprFile: ctx, acc, proto, fpr, trust = line[:-1].split('\t') if acc != self.name or proto != PROTOCOL: continue jid = get_jid_from_fjid(ctx) self.setTrust(jid, fpr, trust) except IOError, e: if e.errno != 2: log.exception('IOError occurred when loading fpr file for %s', self.name) def saveTrusts(self): try: with open(self.keyFilePath + '.fpr', 'w') as fprFile: for uid, trusts in self.trusts.iteritems(): for fpr, trustVal in trusts.iteritems(): fprFile.write('\t'.join( (uid, self.name, PROTOCOL, fpr, trustVal))) fprFile.write('\n') except IOError, e: log.exception('IOError occurred when loading fpr file for %s', self.name) except ImportError: HAS_POTR = False def otr_dialog_destroy(widget, *args, **kwargs): widget.destroy() class OtrPlugin(GajimPlugin): otr = None def init(self): self.us = {} if not HAS_POTR: self.activatable = False self.available_text = _('Can\'t find potr. Verify this ' \ 'plugin\'s integrity.') return if not HAS_CRYPTO: self.activatable = False self.available_text = _('PyCrypto not installed or too old.') return self.config_dialog = ui.OtrPluginConfigDialog(self) self.events_handlers = {} self.events_handlers['message-received'] = (ged.PRECORE, self.handle_incoming_msg) self.events_handlers['before-change-show'] = (ged.PRECORE, self.handle_change_show) if LooseVersion(gajim.config.get('version')) < LooseVersion(MINVERSION_OUTGOING_MSG_STAZA): self.events_handlers['message-outgoing'] = (ged.OUT_PRECORE, self.handle_outgoing_msg) else: self.events_handlers['stanza-message-outgoing'] = (ged.OUT_PRECORE, self.handle_outgoing_msg_stanza) DEFAULTFLAGS['SEND_TAG'] = False self.gui_extension_points = { 'chat_control' : (self.cc_connect, self.cc_disconnect) } for acc in gajim.contacts.get_accounts(): self.us[acc] = GajimOtrAccount(self, acc) self.us[acc].loadTrusts() acc = str(acc) if acc not in self.config or None not in self.config[acc]: self.config[acc] = {None:DEFAULTFLAGS.copy()} self.update_context_list() @log_calls('OtrPlugin') def activate(self): if not HAS_CRYPTO or not HAS_POTR or not hasattr(potr, 'VERSION') \ or potr.VERSION < MINVERSION: raise GajimPluginException(self.available_text) def get_otr_status(self, account, contact): ctx = self.us[account].getContext(contact.get_full_jid()) finished = ctx.state == potr.context.STATE_FINISHED encrypted = finished or ctx.state == potr.context.STATE_ENCRYPTED trusted = encrypted and bool(ctx.getCurrentTrust()) return (encrypted, trusted, finished) def cc_connect(self, cc): def update_otr(print_status=False): enc_status, authenticated, finished = \ self.get_otr_status(cc.account, cc.contact) otr_status_text = '' if finished: otr_status_text = u'finished OTR connection' elif authenticated: otr_status_text = u'authenticated secure OTR connection' elif enc_status: otr_status_text = u'*unauthenticated* secure OTR connection' cc._show_lock_image(enc_status, u'OTR', enc_status, True, authenticated) if print_status and otr_status_text: cc.print_conversation_line(u'[OTR] %s' % otr_status_text, 'status', '', None) cc.update_otr = update_otr cc.update_otr(True) # hijack authentication button with our submenu def authbutton_cb(widget): if not cc.gpg_is_active and not (cc.session and cc.session.enable_encryption): ui.get_otr_submenu(self, cc).get_submenu().popup(None, None, None, 0, 0) else: cc._on_authentication_button_clicked(widget) self.overwrite_handler(cc, cc.authentication_button, authbutton_cb) # hijack context menu cc.orig_prepare_context_menu = cc.prepare_context_menu def inject_menu(hide_buttonbar_items=False): menu = cc.orig_prepare_context_menu(hide_buttonbar_items) menu.insert(ui.get_otr_submenu(self, cc), 8) return menu cc.prepare_context_menu = inject_menu def cc_disconnect(self, cc): try: self.overwrite_handler(cc, cc.authentication_button, cc._on_authentication_button_clicked) cc.prepare_context_menu = cc.orig_prepare_context_menu del cc.update_otr except AttributeError: pass def menu_settings_cb(self, item, control): ctx = self.us[control.account].getContext(control.contact.get_full_jid()) dlg = ui.ContactOtrWindow(self, ctx) dlg.run() dlg.destroy() def menu_start_cb(self, item, control): gajim.nec.push_outgoing_event(MessageOutgoingEvent(None, account=control.account, jid=control.contact.jid, message=u'?OTRv?', type_='chat', resource=control.contact.resource, is_loggable=False)) def menu_end_cb(self, item, control): fjid = control.contact.get_full_jid() thread_id = control.session.thread_id if control.session else None self.us[control.account].getContext(fjid).disconnect( appdata={'thread':thread_id}) def menu_smp_cb(self, item, control): ctx = self.us[control.account].getContext(control.contact.get_full_jid()) ctx.smpWindow.show(False) @staticmethod def overwrite_handler(window, control, handler): for id_, v in window.handlers.iteritems(): if v == control: break else: raise LookupError del window.handlers[id_] control.disconnect(id_) id_ = control.connect('clicked', handler) window.handlers[id_] = control def set_flags(self, value, account=None, contact=None): if isinstance(account, unicode): account = account.encode() if account not in self.config: self.config[account] = {None:DEFAULTFLAGS.copy()} if account is None and contact is not None: # don't set per-contact options without account raise Exception("can't set contact flags without account") config = self.config[account] config[contact] = value self.config[account] = config def get_flags(self, account=None, contact=None, fallback=True): if isinstance(account, unicode): account = account.encode() setting = DEFAULTFLAGS.copy() if account in self.config: setting.update(self.config[account][None]) if contact in self.config[account] \ and self.config[account][contact] is not None: setting.update(self.config[account][contact]) elif not fallback: return None return setting def update_context_list(self): self.config_dialog.fpr_model.clear() for us in self.us.itervalues(): usedFpr = set() for fjid, ctx in us.ctxs.iteritems(): # get active contexts first key = ctx.getCurrentKey() if not key: continue fpr = key.cfingerprint() usedFpr.add(fpr) human_hash = potr.human_hash(fpr) trust = bool(us.getTrust(ctx.trustName, fpr)) if ctx.state == potr.context.STATE_ENCRYPTED: state = "encrypted" tip = enc_tip elif ctx.state == potr.context.STATE_FINISHED: state = "finished" tip = ended_tip else: state = 'inactive' tip = inactive_tip self.config_dialog.fpr_model.append((fjid, state, trust, '%s' % human_hash, us.name, tip, fpr)) for uid, trusts in us.trusts.iteritems(): for fpr, trust in trusts.iteritems(): if fpr in usedFpr: continue state = 'inactive' tip = inactive_tip human_hash = potr.human_hash(fpr) self.config_dialog.fpr_model.append((uid, state, bool(trust), '%s' % human_hash, us.name, tip, fpr)) @classmethod def gajim_log(cls, msg, account, fjid, no_print=False, is_status_message=True, thread_id=None): if not isinstance(fjid, unicode): fjid = unicode(fjid) if not isinstance(account, unicode): account = unicode(account) resource = gajim.get_resource_from_jid(fjid) jid = gajim.get_jid_without_resource(fjid) tim = time.localtime() if is_status_message is True: if not no_print: ctrl = cls.get_control(fjid, account) if ctrl: ctrl.print_conversation_line(u'[OTR] %s' % msg, 'status', '', None) if gajim.config.should_log(account, jid): id = gajim.logger.write('chat_msg_recv', fjid, message=u'[OTR: %s]' % msg, tim=tim) # gajim.logger.write() only marks a message as unread (and so # only returns an id) when fjid is a real contact (NOT if it's a # GC private chat) if id: gajim.logger.set_read_messages([id]) else: session = gajim.connections[account].get_or_create_session(fjid, thread_id) session.received_thread_id |= bool(thread_id) session.last_receive = time.time() if not session.control: # look for an existing chat control without a session ctrl = cls.get_control(fjid, account) if ctrl: session.control = ctrl session.control.set_session(session) if gajim.config.should_log(account, jid): msg_id = gajim.logger.write('chat_msg_recv', fjid, message=u'[OTR: %s]' % msg, tim=tim) session.roster_message(jid, msg, tim=tim, msg_id=msg_id, msg_type='chat', resource=resource) @classmethod def update_otr(cls, user, acc, print_status=False): ctrl = cls.get_control(user, acc) if ctrl: ctrl.update_otr(print_status) @staticmethod def get_control(fjid, account): # first try to get the window with the full jid ctrl = gajim.interface.msg_win_mgr.get_control(fjid, account) if ctrl: # got one, be happy return ctrl # otherwise try without the resource ctrl = gajim.interface.msg_win_mgr.get_control( gajim.get_jid_without_resource(str(fjid)), account) # but only use it when it's not a GC window if ctrl and ctrl.TYPE_ID == TYPE_CHAT: return ctrl def handle_change_show(self, event): account = event.conn.name if event.show == 'offline': for us in self.us.itervalues(): for fjid, ctx in us.ctxs.iteritems(): if ctx.state == potr.context.STATE_ENCRYPTED: self.us[account].getContext(fjid).disconnect() return PASS def handle_incoming_msg(self, event): ctx = None account = event.conn.name accjid = gajim.get_jid_from_account(account) if event.encrypted is not False or not event.stanza.getTag('body') \ or not isinstance(event.stanza.getBody(), unicode) \ or event.mtype != 'chat': return PASS try: ctx = self.us[account].getContext(event.fjid) msgtxt, tlvs = ctx.receiveMessage(event.msgtxt.encode('utf8'), appdata={'thread':event.session.thread_id if event.session else None}) except potr.context.NotOTRMessage, e: # received message was not OTR - pass it on return PASS except potr.context.UnencryptedMessage, e: # we are encrypted but got some plaintext # display it with a warning tlvs = [] msgtxt = _('The following message received from %(jid)s was ' '*not encrypted*: [%(error)s]') % {'jid': event.fjid, 'error': e.args[0]} except potr.context.NotEncryptedError, e: # we got some encrypted data # but we don't have an encrypted session self.gajim_log(_('The encrypted message received from %s is ' 'unreadable, as you are not currently communicating ' 'privately') % event.fjid, account, event.fjid) return IGNORE except potr.context.ErrorReceived, e: # got a protocol error self.gajim_log(_('We received the following OTR error ' 'message from %(jid)s: [%(error)s]') % {'jid': event.fjid, 'error': e.args[0].error}, account, event.fjid) return IGNORE except potr.crypt.InvalidParameterError, e: # received a packet we cannot process (probably tampered or # sent to wrong session) self.gajim_log(_('We received an unreadable OTR message ' 'from %(jid)s. It has probably been tampered with, ' 'or was sent from an older OTR session.') % {'jid':event.fjid}, account, event.fjid) return IGNORE except RuntimeError, e: # generic library bug? self.gajim_log(_('The following error occurred when trying to ' 'decrypt a message from %(jid)s: [%(error)s]') % { 'jid': event.fjid, 'error': e}, account, event.fjid) return IGNORE if ctx is not None: ctx.smpWindow.handle_tlv(tlvs) stripper = HTMLStripper() stripper.feed((msgtxt or '').decode('utf8')) event.msgtxt = stripper.stripped_data event.stanza.setBody(event.msgtxt) if event.stanza.getXHTML(): event.stanza.delChild('html') event.stanza.setXHTML((msgtxt or '').decode('utf8')) return PASS def handle_outgoing_msg_stanza(self, event): xhtml = event.msg_iq.getXHTML() body = event.msg_iq.getBody() encrypted = False thread_id = event.msg_iq.getThread() try: if xhtml: xhtml = xhtml.encode('utf8') encrypted_msg = self.us[event.conn.name].\ getContext(event.msg_iq.getTo()).\ sendMessage(potr.context.FRAGMENT_SEND_ALL_BUT_LAST, xhtml, appdata={'thread': thread_id}) if xhtml != encrypted_msg.strip(): #.strip() because sendMessage() adds whitespaces encrypted = True event.msg_iq.delChild('html') event.msg_iq.setBody(encrypted_msg) elif body: body = escape(body).encode('utf8') encrypted_msg = self.us[event.conn.name].\ getContext(event.msg_iq.getTo()).\ sendMessage(potr.context.FRAGMENT_SEND_ALL_BUT_LAST, body, appdata={'thread': thread_id}) if body != encrypted_msg.strip(): encrypted = True event.msg_iq.setBody(encrypted_msg) except potr.context.NotEncryptedError, e: if e.args[0] == potr.context.EXC_FINISHED: self.gajim_log(msg_not_send, event.conn.name, event.msg_iq.getTo()) return IGNORE else: raise e if encrypted: add_message_processing_hints(event.msg_iq) return PASS def handle_outgoing_msg(self, event): try: if hasattr(event, 'otrmessage'): return PASS xep_200 = bool(event.session) and event.session.enable_encryption potrrootlog.debug('got event {0} xep_200={1}'.format(pformat(event.__dict__), xep_200)) if xep_200 or not event.message: return PASS if event.session: fjid = event.session.get_to() else: fjid = event.jid if event.resource: fjid += '/' + event.resource message = event.xhtml or escape(event.message) message = message.encode('utf8') potrrootlog.debug('processing message={0!r} from fjid={1!r}'.format(message, fjid)) try: newmsg = self.us[event.account].getContext(fjid).sendMessage( potr.context.FRAGMENT_SEND_ALL_BUT_LAST, message, appdata={'thread':event.session.thread_id if event.session else None}) potrrootlog.debug('processed message={0!r}'.format(newmsg)) except potr.context.NotEncryptedError, e: if e.args[0] == potr.context.EXC_FINISHED: self.gajim_log(msg_not_send, event.account, fjid) return IGNORE else: raise e if event.xhtml: # if we had html before, replace with new content event.xhtml = newmsg stripper = HTMLStripper() stripper.feed((newmsg or '').decode('utf8')) event.message = stripper.stripped_data return PASS except: potrrootlog.exception('exception in outgoing message handler, message (hopefully) discarded') return IGNORE class HTMLStripper(HTMLParser): def reset(self): self.stripped_data = '' HTMLParser.reset(self) def handle_data(self, data): self.stripped_data += data def handle_starttag(self, tag, attrs): if tag == 'br': self.stripped_data += '\n' def handle_entityref(self, name): try: c = unichr(name2codepoint[name]) except KeyError: c = '&{};'.format(name) self.stripped_data += c def handle_charref(self, name): if name.startswith('x'): c = unichr(int(name[1:], 16)) else: c = unichr(int(name)) self.stripped_data += c def unknown_decl(self, data): if data.startswith('CDATA['): self.stripped_data += data[6:] def feed(self, data): data = data.replace('\n', '') HTMLParser.feed(self, data) def escape(s): '''Replace special characters "&", "<" and ">" to HTML-safe sequences. If the optional flag quote is true, the quotation mark character (") is also translated.''' s = s.replace("&", "&") # Must be done first! s = s.replace("<", "<") s = s.replace(">", ">") s = s.replace("\n", "
") return s def add_message_processing_hints(stanza): stanza.addChild(name='private', namespace=nbxmpp.NS_CARBONS) stanza.addChild(name='no-permanent-store', namespace=nbxmpp.NS_MSG_HINTS) stanza.addChild(name='no-copy', namespace=nbxmpp.NS_MSG_HINTS)