view src/browser/sat_browser/plugin_sec_otr.py @ 522:0de69fec24e9

browser and server sides: OTR plugin, first draft
author souliane <souliane@mailoo.org>
date Tue, 02 Sep 2014 21:28:42 +0200
parents
children 5add182e7dd5
line wrap: on
line source

#!/usr/bin/python
# -*- coding: utf-8 -*-

# Libervia plugin for OTR encryption
# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014 Jérôme Poisson (goffi@goffi.org)
# Copyright (C) 2013, 2014 Adrien Cossa (souliane@mailoo.org)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program 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 Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""
This file is adapted from sat.plugins.plugin.sec_otr. It offers browser-side OTR encryption using otr.js.
The text messages to display are mostly taken from the Pidgin OTR plugin (GPL 2.0, see http://otr.cypherpunks.ca).
"""

from sat.core.i18n import _, D_
from sat.core.log import getLogger
from sat.core import exceptions
log = getLogger(__name__)

from constants import Const as C
import jid
import otrjs_wrapper as otr
import dialog

NS_OTR = "otr_plugin"
PRIVATE_KEY = "PRIVATE KEY"
MAIN_MENU = D_('OTR')
DIALOG_EOL = "<br />"
AUTH_INFO_TXT = D_("Authenticating a buddy helps ensure that the person you are talking to is who he or she claims to be.{eol}{eol}").format(eol=DIALOG_EOL)
AUTH_FINGERPRINT_TXT = D_("<i>To verify the fingerprint, contact your buddy via some other authenticated channel (i.e. not in this chat), such as the telephone or GPG-signed email. Each of you should tell your fingerprint to the other.</i>{eol}{eol}").format(eol=DIALOG_EOL)
AUTH_QUEST_DEF = D_("<i>To authenticate using a question, pick a question whose answer is known only to you and your buddy. Enter this question and this answer, then wait for your buddy to enter the answer too. If the answers don't match, then you may be talking to an imposter.</i>{eol}{eol}").format(eol=DIALOG_EOL)
AUTH_QUEST_ASK = D_("<i>Your buddy is attempting to determine if he or she is really talking to you, or if it's someone pretending to be you. Your buddy has asked a question, indicated below. To authenticate to your buddy, enter the answer and click OK.</i>{eol}{eol}").format(eol=DIALOG_EOL)
AUTH_SECRET_TXT = D_("{eol}{eol}Enter secret answer here: (case sensitive){eol}").format(eol=DIALOG_EOL)


DROP_TXT = D_("You private key is used to encrypt messages for your correspondent, nobody except you must know it, if you are in doubt, you should drop it!{eol}{eol}Are you sure you want to drop your private key?").format(eol=DIALOG_EOL)

DEFAULT_POLICY_FLAGS = {
    'ALLOW_V2': True,
    'ALLOW_V3': True,
    'REQUIRE_ENCRYPTION': False,
}


class Context(otr.context.Context):

    def __init__(self, host, account, other_jid):
        """

        @param host (satWebFrontend)
        @param account (Account)
        @param other_jid (JID): JID of the person your chat buddy
        """
        super(Context, self).__init__(account, other_jid)
        self.host = host

    def getPolicy(self, key):
        """Get the value of the specified policy

        @param key (str): a value in:
            - ALLOW_V1 (apriori removed from otr.js)
            - ALLOW_V2
            - ALLOW_V3
            - REQUIRE_ENCRYPTION
            - SEND_WHITESPACE_TAG
            - WHITESPACE_START_AKE
            - ERROR_START_AKE
        @return: str
        """
        if key in DEFAULT_POLICY_FLAGS:
            return DEFAULT_POLICY_FLAGS[key]
        else:
            return False

    def receiveMessageCb(self, msg, encrypted):
        assert isinstance(self.peer, jid.JID)
        log.debug("message received (was %s): %s" % ('encrypted' if encrypted else 'plain', msg))
        if not encrypted:
            if self.state == otr.context.STATE_ENCRYPTED:
                log.warning(u"Received unencrypted message in an encrypted context (from %(jid)s)" % {'jid': self.peer.full()})
                feedback = _(u"WARNING: received unencrypted data in a supposedly encrypted context"),
                self.host.newMessageCb(self.peer, feedback, "headline", self.host.whoami, {})
        self.host.newMessageCb(self.peer, msg, "chat", self.host.whoami, {})

    def sendMessageCb(self, msg, meta=None):
        assert isinstance(self.peer, jid.JID)
        log.debug("message to send%s: %s" % ((' (attached meta data: %s)' % meta) if meta else '', msg))
        self.host.bridge.call('sendMessage', (None, self.host.sendError), self.peer.full(), msg, '', 'chat', {'send_only': 'true'})

    def messageErrorCb(self, error):
        log.error('error occured: %s' % error)

    def setStateCb(self, msg_state, status):
        other_jid_s = self.peer.full()
        feedback = _(u"Error: the state of the conversation with %s is unknown!")

        if status == otr.context.STATUS_AKE_INIT:
            return

        elif status == otr.context.STATUS_SEND_QUERY:
            if msg_state in (otr.context.STATE_PLAINTEXT, otr.context.STATE_FINISHED):
                feedback = _('Attempting to start a private conversation with %s...')
            elif msg_state == otr.context.STATE_ENCRYPTED:
                feedback = _('Attempting to refresh the private conversation with %s...')

        elif status == otr.context.STATUS_AKE_SUCCESS:
            trusted_str = _(u"Verified") if self.getCurrentTrust() else _(u"Unverified")
            if msg_state == otr.context.STATE_ENCRYPTED:
                feedback = trusted_str + (u" conversation with %s started. Your client is not logging this conversation.")
            else:
                feedback = _("Error: successfully ake'd with %s but the conversation is not private!")

        elif status == otr.context.STATUS_END_OTR:
            if msg_state == otr.context.STATE_PLAINTEXT:
                feedback = _("You haven't start any private conversation with %s yet.")
            elif msg_state == otr.context.STATE_ENCRYPTED:
                feedback = _("%s has ended his/her private conversation with you; you should do the same.")
            elif msg_state == otr.context.STATE_FINISHED:
                feedback = _("Private conversation with %s lost.")

        self.host.newMessageCb(self.peer, feedback % other_jid_s, "headline", self.host.whoami, {})

    def setCurrentTrust(self, new_trust='', act='asked', type_='trust'):
        log.debug("setCurrentTrust: trust={trust}, act={act}, type={type}".format(type=type_, trust=new_trust, act=act))
        title = (_("Authentication of {jid}") if act == "asked" else _("Authentication to {jid}")).format(jid=self.peer.full())
        if type_ == 'abort':
            msg = _("Authentication aborted.")
        elif new_trust:
            if act == "asked":
                msg = _("Authentication successful.")
            else:
                msg = _("Your buddy has successfully authenticated you. You may want to authenticate your buddy as well by asking your own question.")
        else:
            msg = _("Authentication failed.")
        dialog.InfoDialog(title, msg).show()
        if act != "asked":
            return
        old_trust = self.getCurrentTrust()
        otr.context.Context.setCurrentTrust(self, new_trust)
        if old_trust != new_trust:
            feedback = _("The privacy status of the current conversation is now: {state}").format(state='Private' if new_trust else 'Unverified')
            self.host.newMessageCb(self.peer, feedback, "headline", self.host.whoami, {})

    def fingerprintAuthCb(self):
        """OTR v2 authentication using manual fingerprint comparison"""
        priv_key = self.user.privkey

        if priv_key is None:  # OTR._authenticate should not let us arrive here
            raise exceptions.InternalError
            return

        other_key = self.getCurrentKey()
        if other_key is None:
            # we have a private key, but not the fingerprint of our correspondent
            msg = AUTH_INFO_TXT + ("Your fingerprint is:{eol}{fingerprint}{eol}{eol}Start an OTR conversation to have your correspondent one.").format(fingerprint=priv_key.fingerprint(), eol=DIALOG_EOL)
            dialog.InfoDialog(_("Fingerprint"), msg).show()
            return

        def setTrust(confirm):
            self.setCurrentTrust('fingerprint' if confirm else '')

        text = AUTH_INFO_TXT + AUTH_FINGERPRINT_TXT + _("Fingerprint for you, {jid}:{eol}{fingerprint}{eol}{eol}").format(jid=self.host.whoami, fingerprint=priv_key.fingerprint(), eol=DIALOG_EOL)
        text += _("Purported fingerprint for {jid}:{eol}{fingerprint}{eol}{eol}").format(jid=self.peer, fingerprint=other_key.fingerprint(), eol=DIALOG_EOL)
        text += _("Did you verify that this is in fact the correct fingerprint for {jid}?").format(jid=self.peer)
        title = _('Authentication of {jid}').format(jid=self.peer.full())
        dialog.ConfirmDialog(setTrust, text, title).show()

    def smpAuthCb(self, type_, data, act=None):
        """OTR v3 authentication using the socialist millionaire protocol.

        @param type_ (str): a value in ('question', 'trust', 'abort')
        @param data (str, bool): this could be:
            - a string containing the question if type_ is 'question'
            - a boolean value telling if the authentication succeed when type_ is 'trust'
        @param act (str): a value in ('asked', 'answered')
        """
        log.debug("smpAuthCb: type={type}, data={data}, act={act}".format(type=type_, data=data, act=act))
        if act is None:
            if type_ == 'question':
                act = 'answered'  # OTR._authenticate calls this method with act="asked"
            elif type_ == 'abort':
                act = 'asked'  # smpAuthAbort triggers this method with act='answered' when needed

                # FIXME upstream: if buddy uses Pidgin and use fingerprint to authenticate us,
                # we will reach this code... that's wrong, this method is for SMP! There's
                # probably a bug to fix in otr.js. Do it together with the issue that make
                # us need the dirty self.smpAuthAbort.
            else:
                log.error("FIXME: unmanaged ambiguous 'act' value in Context.smpAuthCb!")
        title = (_("Authentication of {jid}") if act == "asked" else _("Authentication to {jid}")).format(jid=self.peer.full())
        if type_ == 'question':
            if act == 'asked':
                def cb(question, answer=None):
                    if question is False or not answer:  # dialog cancelled or the answer is empty
                        return
                    self.smpAuthSecret(answer, question)
                text = AUTH_INFO_TXT + AUTH_QUEST_DEF + _("Enter question here:{eol}").format(eol=DIALOG_EOL, question=data)
                dialog.PromptDialog(cb, [text, AUTH_SECRET_TXT], title=title).show()
            else:
                def cb(answer):
                    if not answer:  # dialog cancelled or the answer is empty
                        self.smpAuthAbort('answered')
                        return
                    self.smpAuthSecret(answer)
                text = AUTH_INFO_TXT + AUTH_QUEST_ASK + _("This is the question asked by your buddy:{eol}{question}").format(eol=DIALOG_EOL, question=data)
                dialog.PromptDialog(cb, text + AUTH_SECRET_TXT, title=title).show()
        elif type_ == 'trust':
            self.setCurrentTrust('smp' if data else '', act)
        elif type_ == 'abort':
            self.setCurrentTrust('', act, 'abort')


class Account(otr.context.Account):

    def __init__(self, host):
        log.debug(u"new account: %s" % host.whoami.full())
        if not host.whoami.resource:
            log.warning("Account created without resource")
        super(Account, self).__init__(host.whoami)
        self.host = host

    def loadPrivkey(self):
        return self.privkey

    def savePrivkey(self):
        # TODO: serialize and encrypt the private key and save it to a HTML5 persistent storage
        # We need to ask the user before saving the key (e.g. if he's not on his private machine)
        # self.privkey.serializePrivateKey() --> encrypt --> store
        if self.privkey is None:
            raise exceptions.InternalError(_("Save is called but privkey is None !"))
        pass

    def saveTrusts(self):
        # TODO save the trusts as it would be done for the private key
        pass


class ContextManager(object):

    def __init__(self, host):
        self.host = host
        self.account = Account(host)
        self.contexts = {}

    def startContext(self, other_jid):
        assert isinstance(other_jid, jid.JID)
        # FIXME upstream: apparently pyjamas doesn't implement setdefault well, it ignores JID.__hash__ redefinition
        #context = self.contexts.setdefault(other_jid, Context(self.host, self.account, other_jid))
        if other_jid not in self.contexts:
            self.contexts[other_jid] = Context(self.host, self.account, other_jid)
        return self.contexts[other_jid]

    def getContextForUser(self, other):
        log.debug(u"getContextForUser [%s]" % other)
        if not other.resource:
            log.error("getContextForUser called with a bare jid")
        return self.startContext(other)


class OTR(object):

    def __init__(self, host):
        log.info(_(u"OTR plugin initialization"))
        self.host = host
        self.context_manager = None
        self.last_resources = {}
        self.host.bridge._registerMethods(["skipOTR"])

    def inhibitMenus(self):
        """Tell the caller which dynamic menus should be inhibited"""
        return ["OTR"]  # menu categories name to inhibit

    def extraMenus(self):
        # FIXME: handle help strings too
        return [(self._startRefresh, C.MENU_SINGLE, (MAIN_MENU, "Start/Refresh"), (MAIN_MENU, D_("Start/Refresh"))),
                (self._endSession, C.MENU_SINGLE, (MAIN_MENU, "End session"), (MAIN_MENU, D_("End session"))),
                (self._authenticate, C.MENU_SINGLE, (MAIN_MENU, "Authenticate buddy"), (MAIN_MENU, D_("Authenticate buddy"))),
                (self._dropPrivkey, C.MENU_SINGLE, (MAIN_MENU, "Drop private key"), (MAIN_MENU, D_("Drop private key")))]

    def profileConnected(self):
        self.host.bridge.call('skipOTR', None)
        self.context_manager = ContextManager(self.host)
        # TODO: retrieve the encrypted private key from a HTML5 persistent storage,
        # decrypt it, parse it with otr.crypt.PK.parsePrivateKey(privkey) and
        # assign it to self.context_manager.account.privkey

    def fixResource(self, jid, cb):
        # FIXME: it's dirty, but libervia doesn't manage resources correctly now, refactoring is planed
        if jid.resource:
            cb(jid)
        elif jid.bare in self.last_resources:
            jid.resource = self.last_resources[jid.bare]
            cb(jid)
        else:
            def gotResource(resource):
                jid.setResource(resource)
                cb(jid)
            self.host.bridge.call('getLastResource', gotResource, jid.full())

    def messageReceivedTrigger(self, from_jid, msg, msg_type, to_jid, extra):
        other_jid = to_jid if from_jid.bare == self.host.whoami.bare else from_jid

        def cb(jid):
            otrctx = self.context_manager.getContextForUser(jid)
            otrctx.receiveMessage(msg)
            return False  # interrupt the main process

        self.fixResource(other_jid, cb)

    def sendMessageTrigger(self, to_jid, msg, msg_type, extra):
        def cb(jid):
            otrctx = self.context_manager.getContextForUser(jid)
            if msg_type != 'groupchat' and otrctx.state == otr.context.STATE_ENCRYPTED:
                log.debug(u"encrypting message")
                otrctx.sendMessage(msg)
                self.host.newMessageCb(self.host.whoami, msg, msg_type, jid, extra)
            else:
                log.debug(u"sending message unencrypted")
                self.host.bridge.call('sendMessage', (None, self.host.sendError), to_jid.full(), msg, '', msg_type, extra)

        if msg_type != 'groupchat':
            self.fixResource(to_jid, cb)
        else:
            cb(to_jid)
        return False  # interrupt the main process

    # Menu callbacks

    def _startRefresh(self, menu_data):
        """Start or refresh an OTR session

        @param menu_data: %(menu_data)s
        """
        def cb(other_jid):
            otrctx = self.context_manager.getContextForUser(other_jid)
            otrctx.sendQueryMessage()

        try:
            other_jid = menu_data['jid']
            self.fixResource(other_jid, cb)
        except KeyError:
            log.error(_("jid key is not present !"))
            return None

    def _endSession(self, menu_data):
        """End an OTR session

        @param menu_data: %(menu_data)s
        """
        def cb(other_jid):
            otrctx = self.context_manager.getContextForUser(other_jid)
            otrctx.disconnect()
        try:
            other_jid = menu_data['jid']
            self.fixResource(other_jid, cb)
        except KeyError:
            log.error(_("jid key is not present !"))
            return None

    def _authenticate(self, menu_data, profile):
        """Authenticate other user and see our own fingerprint

        @param menu_data: %(menu_data)s
        @param profile: %(doc_profile)s
        """
        def cb(to_jid):
            otrctx = self.context_manager.getContextForUser(to_jid)
            otr_version = otrctx.getUsedVersion()
            if otr_version == otr.context.OTR_VERSION_2:
                otrctx.fingerprintAuthCb()
            elif otr_version == otr.context.OTR_VERSION_3:
                otrctx.smpAuthCb('question', None, 'asked')
            else:
                dialog.InfoDialog(_("No running session"), _("You must start a private conversation before authenticating your buddy.")).show()

        try:
            to_jid = menu_data['jid']
            self.fixResource(to_jid, cb)
        except KeyError:
            log.error(_("jid key is not present !"))
            return None

    def _dropPrivkey(self, menu_data, profile):
        """Drop our private Key

        @param menu_data: %(menu_data)s
        @param profile: %(doc_profile)s
        """
        def cb(to_jid):
            priv_key = self.context_manager.account.privkey

            if priv_key is None:
                # we have no private key yet
                dialog.InfoDialog(_("No private key"), _("You don't have any private key yet!")).show()
                return

            def dropKey(confirm):
                if confirm:
                    # we end all sessions
                    for context in self.context_manager.contexts.values():
                        if context.state not in (otr.context.STATE_FINISHED, otr.context.STATE_PLAINTEXT):
                            context.disconnect()
                    self.context_manager.account.privkey = None
                    self.context_manager.account.getPrivkey()  # as account.privkey are None, getPrivkey will generate a new key, and save it
                    dialog.InfoDialog(_("Your private key has been dropped"), _('Drop')).show()

            text = _(DROP_TXT)
            title = _('Confirm private key drop')
            dialog.ConfirmDialog(dropKey, text, title).show()

        try:
            to_jid = menu_data['jid']
            self.fixResource(to_jid, cb)
        except KeyError:
            log.error(_("jid key is not present !"))
            return None