otr.py 37.4 KB
Newer Older
mathieui's avatar
mathieui committed
1 2 3 4
"""

This plugin implements `Off The Record messaging`_.

mathieui's avatar
mathieui committed
5
This is a plugin used to encrypt a one-to-one conversation using the OTR
mathieui's avatar
mathieui committed
6
encryption method. You can use it if you want good privacy, deniability,
7 8 9 10 11 12
authentication, and strong secrecy. Without this encryption, your messages
are encrypted **at least** from your client (poezio) to your server. The
message is decrypted by your server and you cannot control the encryption
method of your messages from your server to your contact’s server (unless
you are your own server’s administrator), nor from your contact’s server
to your contact’s client.
mathieui's avatar
mathieui committed
13 14 15 16 17 18

This plugin does end-to-end encryption. This means that **only** your contact can
decrypt your messages, and it is fully encrypted during **all** its travel
through the internet.

Note that if you are having an encrypted conversation with a contact, you can
mathieui's avatar
mathieui committed
19 20
**not** send XHTML-IM messages to them (or correct messages, or anything more than
raw text). All formatting will be removed and be replaced by plain text messages.
21 22 23 24

This is a limitation of the OTR protocol, and it will never be fixed. Some clients
like Pidgin-OTR try do do magic stuff with html unescaping inside the OTR body, and
it is not pretty.
mathieui's avatar
mathieui committed
25

26 27
Installation
------------
mathieui's avatar
mathieui committed
28

mathieui's avatar
mathieui committed
29 30
To use the OTR plugin, you must first install pure-python-otr and pycrypto
(for python3).
mathieui's avatar
mathieui committed
31

32 33 34
You have to install it from the git because a few issues were
found with the python3 compatibility while writing this plugin,
and the fixes did not make it into a stable release yet.
mathieui's avatar
mathieui committed
35

36
Install the python module:
mathieui's avatar
mathieui committed
37 38 39

.. code-block:: bash

40 41 42
    git clone https://github.com/afflux/pure-python-otr.git
    cd pure-python-otr
    python3 setup.py install --user
mathieui's avatar
mathieui committed
43

mathieui's avatar
mathieui committed
44 45
You can also use pip in a virtualenv (built-in as pyvenv_ with python since 3.3)
with the requirements.txt at the root of the poezio directory.
mathieui's avatar
mathieui committed
46 47


48 49
Usage
-----
mathieui's avatar
mathieui committed
50

51
Command added to Conversation Tabs and Private Tabs:
mathieui's avatar
mathieui committed
52

53
.. glossary::
mathieui's avatar
mathieui committed
54

55
    /otr
56
        **Usage:** ``/otr [start|refresh|end|fpr|ourfpr|trust|untrust]``
mathieui's avatar
mathieui committed
57

58
        This command is used to manage an OTR private session.
mathieui's avatar
mathieui committed
59

60 61
        - The ``start`` (or ``refresh``) command starts or refreshs a private OTR session
        - The ``end`` command ends a private OTR session
62
        - The ``fpr`` command gives you the fingerprint of the key of the remote entity
63 64 65
        - The ``ourfpr`` command gives you the fingerprint of your own key
        - The ``trust`` command marks the current remote key as trusted for the current remote JID
        - The ``untrust`` command removes that trust
66 67 68 69
        - Finally, the ``drop`` command is used if you want to delete your private key (not recoverable).

        .. warning::

70 71 72
            With ``drop``, the private key is only removed from the filesystem,
            *NOT* with multiple rewrites in a secure manner, you should do that
            yourself if you want to be sure.
mathieui's avatar
mathieui committed
73

mathieui's avatar
mathieui committed
74 75 76 77 78 79 80
    /otrsmp
        **Usage:** ``/otrsmp <ask|answer|abort> [question] [secret]``

        Verify the identify of your contact by using a pre-defined secret.

        - The ``abort`` command aborts an ongoing verification
        - The ``ask`` command start a verification, with a question or not
mathieui's avatar
mathieui committed
81
        - The ``answer`` command sends back the answer and finishes the verification
mathieui's avatar
mathieui committed
82

mathieui's avatar
mathieui committed
83 84
Managing trust
--------------
mathieui's avatar
mathieui committed
85

mathieui's avatar
mathieui committed
86 87 88
An OTR conversation can be started with a simple ``/otr start`` and the
conversation will be encrypted. However it is very often useful to check
that your are talking to the right person.
mathieui's avatar
mathieui committed
89

mathieui's avatar
mathieui committed
90 91
To this end, two actions are available, and a message explaining both
will be prompted each time an **untrusted** conversation is started:
mathieui's avatar
mathieui committed
92

mathieui's avatar
mathieui committed
93 94 95
- Checking the knowledge of a shared secret through the use of :term:`/otrsmp`
- Exchanging fingerprints (``/otr fpr`` and ``/otr ourfpr``) out of band (in a secure channel) to check that both match,
  then use ``/otr trust`` to add then to the list of trusted fingerprints for this JID.
mathieui's avatar
mathieui committed
96

97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117
Files
-----

This plugin creates trust files complatible with libotr and the files produced by gajim.


The files are located in :file:`$XDG_DATA_HOME/poezio/otr/` by default (so
:file:`~/.local/share/poezio/otr` in most cases).

Two files are created:

- An account_jid.key3 (:file:`example@example.com.key3`) file, which contains the private key
- An account_jid.fpr (:file:`example@example.com.fpr`) file, which contains the list of trusted
  (or untrusted) JIDs and keys.

Configuration
-------------

.. glossary::
    :sorted:

118 119 120 121 122
    decode_xhtml
        **Default:** ``true``

        Decode embedded XHTML.

123
    decode_entities
124
        **Default:** ``true``
125 126 127 128 129

        Decode XML and HTML entities (like ``&amp;``) even when the
        document isn't valid (if it is valid, it will be decoded even
        without this option).

130 131 132 133 134 135 136 137
    decode_newlines
        **Default:** ``true``

        Decode ``<br/>`` and ``<br>`` tags even when the document
        isn't valid (if it is valid, it will be decoded even
        without this option for ``<br/>``, and ``<br>`` will make
        the document invalid anyway).

138 139 140 141 142
    keys_dir
        **Default:** ``$XDG_DATA_HOME/poezio/otr``

        The directory in which you want keys and fpr to be stored.

mathieui's avatar
mathieui committed
143
    require_encryption
144 145
        **Default:** ``false``

mathieui's avatar
mathieui committed
146 147
        If ``true``, prevents you from sending unencrypted messages, and tries
        to establish OTR sessions when receiving unencrypted messages.
148

149 150 151 152 153 154 155
    timeout
        **Default:** ``3``

        The number of seconds poezio will wait until notifying you
        that the OTR session was not established. A negative or null
        value will disable this notification.

156
    log
mathieui's avatar
mathieui committed
157
        **Default:** ``false``
158 159 160

        Log conversations (OTR start/end marker, and messages).

mathieui's avatar
mathieui committed
161
The :term:`require_encryption`, :term:`decode_xhtml`, :term:`decode_entities`
162
and :term:`log` configuration parameters are tab-specific.
mathieui's avatar
mathieui committed
163

164 165
Important details
-----------------
mathieui's avatar
mathieui committed
166

mathieui's avatar
mathieui committed
167 168 169 170
The OTR session is considered for a full JID (e.g. toto@example/**client1**),
but the trust is set with a bare JID (e.g. toto@example). This is important
in the case of Private Chats (in a chatroom), since you cannot always get the
real JID of your contact (or check if the same nick is used by different people).
mathieui's avatar
mathieui committed
171

172
.. _Off The Record messaging: http://wiki.xmpp.org/web/OTR
mathieui's avatar
mathieui committed
173
.. _pyvenv: https://docs.python.org/3/using/scripts.html#pyvenv-creating-virtual-environments
mathieui's avatar
mathieui committed
174

175
"""
mathieui's avatar
mathieui committed
176

177
from gettext import gettext as _
178
import potr
mathieui's avatar
mathieui committed
179
import logging
180

mathieui's avatar
mathieui committed
181
log = logging.getLogger(__name__)
182
import os
183
import html
184 185
import curses

186 187
from potr.context import NotEncryptedError, UnencryptedMessage, ErrorReceived, NotOTRMessage,\
        STATE_ENCRYPTED, STATE_PLAINTEXT, STATE_FINISHED, Context, Account, crypt
mathieui's avatar
mathieui committed
188

189 190 191 192 193 194 195 196
from poezio import common
from poezio import xhtml
from poezio.common import safeJID
from poezio.config import config
from poezio.plugin import BasePlugin
from poezio.tabs import ConversationTab, DynamicConversationTab, PrivateTab
from poezio.theming import get_theme, dump_tuple
from poezio.decorators import command_args_parser
197
from poezio.core.structs import Completion
198 199

OTR_DIR = os.path.join(os.getenv('XDG_DATA_HOME') or
mathieui's avatar
mathieui committed
200
                       '~/.local/share', 'poezio', 'otr')
mathieui's avatar
mathieui committed
201

202
POLICY_FLAGS = {
mathieui's avatar
mathieui committed
203 204 205 206 207 208
    'ALLOW_V1':False,
    'ALLOW_V2':True,
    'REQUIRE_ENCRYPTION': False,
    'SEND_TAG': True,
    'WHITESPACE_START_AKE': True,
    'ERROR_START_AKE': True
209 210 211 212
}

log = logging.getLogger(__name__)

213

mathieui's avatar
mathieui committed
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229
OTR_TUTORIAL = _(
"""%(info)sThis contact has not yet been verified.
You have several methods of authentication available:

1) Verify each other's fingerprints using a secure (and different) channel:
Your fingerprint: %(normal)s%(our_fpr)s%(info)s
%(jid_c)s%(jid)s%(info)s's fingerprint: %(normal)s%(remote_fpr)s%(info)s
Then use the command: /otr trust

2) SMP pre-shared secret you both know:
/otrsmp ask <secret>

3) SMP pre-shared secret you both know with a question:
/otrsmp ask <question> <secret>
""")

mathieui's avatar
mathieui committed
230
OTR_NOT_ENABLED = _('%(jid_c)s%(jid)s%(info)s did not enable '
231
                    'OTR after %(secs)s seconds.')
mathieui's avatar
mathieui committed
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307

MESSAGE_NOT_SENT = _('%(info)sYour message to %(jid_c)s%(jid)s%(info)s was'
                     ' not sent because your configuration requires an '
                     'encrypted session.\nWait until it is established or '
                     'change your configuration.')

OTR_REQUEST = _('%(info)sOTR request to %(jid_c)s%(jid)s%(info)s sent.')

OTR_OWN_FPR = _('%(info)sYour OTR key fingerprint is '
                '%(normal)s%(fpr)s%(info)s.')

OTR_REMOTE_FPR = _('%(info)sThe key fingerprint for %(jid_c)s'
                   '%(jid)s%(info)s is %(normal)s%(fpr)s%(info)s.')

OTR_NO_FPR = _('%(jid_c)s%(jid)s%(info)s has no'
               ' key currently in use.')

OTR_START_TRUSTED = _('%(info)sStarted a \x19btrusted\x19o%(info)s '
                      'OTR conversation with %(jid_c)s%(jid)s')

OTR_REFRESH_TRUSTED = _('%(info)sRefreshed \x19btrusted\x19o%(info)s'
                        ' OTR conversation with %(jid_c)s%(jid)s')

OTR_START_UNTRUSTED = _('%(info)sStarted an \x19buntrusted\x19o%(info)s'
                        ' OTR conversation with %(jid_c)s%(jid)s')

OTR_REFRESH_UNTRUSTED = _('%(info)sRefreshed \x19buntrusted\x19o%(info)s'
                          ' OTR conversation with %(jid_c)s%(jid)s')

OTR_END = _('%(info)sEnded OTR conversation with %(jid_c)s%(jid)s')

SMP_REQUESTED = _('%(jid_c)s%(jid)s%(info)s has requested SMP verification'
                  '%(q)s%(info)s.\nAnswer with: /otrsmp answer <secret>')

SMP_INITIATED = _('%(info)sInitiated SMP request with '
                  '%(jid_c)s%(jid)s%(info)s.')

SMP_PROGRESS = _('%(info)sSMP progressing.')

SMP_RECIPROCATE = _('%(info)sYou may want to authenticate your peer by asking'
                    ' your own question: /otrsmp ask [question] <secret>')

SMP_SUCCESS = _('%(info)sSMP Verification \x19bsucceeded\x19o%(info)s.')

SMP_FAIL = _('%(info)sSMP Verification \x19bfailed\x19o%(info)s.')

SMP_ABORTED_PEER = _('%(info)sSMP aborted by peer.')

SMP_ABORTED = _('%(info)sSMP aborted.')

MESSAGE_UNENCRYPTED = _('%(info)sThe following message from %(jid_c)s%(jid)s'
                        '%(info)s was \x19bnot\x19o%(info)s encrypted:\x19o\n'
                        '%(msg)s')

MESSAGE_UNREADABLE = _('%(info)sAn encrypted message from %(jid_c)s%(jid)s'
                       '%(info)s was received but is unreadable, as you are'
                       ' not currently communicating privately.')

MESSAGE_INVALID = _('%(info)sThe message from %(jid_c)s%(jid)s%(info)s'
                    ' could not be decrypted.')

OTR_ERROR = _('%(info)sReceived the following error from '
              '%(jid_c)s%(jid)s%(info)s:\x19o %(err)s')

POTR_ERROR = _('%(info)sAn unspecified error in the OTR plugin occured:\n'
               '%(exc)s')

TRUST_ADDED = _('%(info)sYou added %(jid_c)s%(bare_jid)s%(info)s with key '
                '\x19o%(key)s%(info)s to your trusted list.')


TRUST_REMOVED = _('%(info)sYou removed %(jid_c)s%(bare_jid)s%(info)s with '
                  'key \x19o%(key)s%(info)s from your trusted list.')

KEY_DROPPED = _('%(info)sPrivate key dropped.')

308
def hl(tab):
mathieui's avatar
mathieui committed
309 310 311
    """
    Make a tab beep and change its status.
    """
312 313 314 315 316
    if tab.state != 'current':
        tab.state = 'private'

    conv_jid = safeJID(tab.name)
    if 'private' in config.get('beep_on', 'highlight private').split():
317
        if not config.get_by_tabname('disable_beep', conv_jid.bare, default=False):
318 319
            curses.beep()

320
class PoezioContext(Context):
mathieui's avatar
mathieui committed
321 322 323 324 325
    """
    OTR context, specific to a conversation with a contact

    Overrides methods from potr.context.Context
    """
326 327 328 329 330
    def __init__(self, account, peer, xmpp, core):
        super(PoezioContext, self).__init__(account, peer)
        self.xmpp = xmpp
        self.core = core
        self.flags = {}
331
        self.trustName = safeJID(peer).bare
mathieui's avatar
mathieui committed
332 333
        self.in_smp = False
        self.smp_own = False
mathieui's avatar
mathieui committed
334
        self.log = 0
335 336 337 338 339 340 341

    def getPolicy(self, key):
        if key in self.flags:
            return self.flags[key]
        else:
            return False

mathieui's avatar
mathieui committed
342 343 344 345
    def reset_smp(self):
        self.in_smp = False
        self.smp_own = False

346
    def inject(self, msg, appdata=None):
347 348 349
        message = self.xmpp.make_message(mto=self.peer,
                                         mbody=msg.decode('ascii'),
                                         mtype='chat')
350
        message.enable('carbon_private')
351 352
        message.enable('no-copy')
        message.enable('no-permanent-store')
353
        message.send()
354 355

    def setState(self, newstate):
mathieui's avatar
mathieui committed
356 357 358 359 360 361 362
        format_dict = {
            'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
            'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
            'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT),
            'jid': self.peer,
            'bare_jid': safeJID(self.peer).bare
        }
363

364 365
        tab = self.core.get_tab_by_name(self.peer)
        if not tab:
366 367
            tab = self.core.get_tab_by_name(safeJID(self.peer).bare,
                                            DynamicConversationTab)
mathieui's avatar
mathieui committed
368
            if tab and not tab.locked_resource == safeJID(self.peer).resource:
369 370
                tab = None
        if self.state == STATE_ENCRYPTED:
mathieui's avatar
mathieui committed
371
            if newstate == STATE_ENCRYPTED and tab:
372
                log.debug('OTR conversation with %s refreshed', self.peer)
mathieui's avatar
mathieui committed
373
                if self.getCurrentTrust():
mathieui's avatar
mathieui committed
374
                    msg = OTR_REFRESH_TRUSTED % format_dict
mathieui's avatar
mathieui committed
375 376
                    tab.add_message(msg, typ=self.log)
                else:
mathieui's avatar
mathieui committed
377
                    msg = OTR_REFRESH_UNTRUSTED % format_dict
mathieui's avatar
mathieui committed
378 379
                    tab.add_message(msg, typ=self.log)
                hl(tab)
380 381 382
            elif newstate == STATE_FINISHED or newstate == STATE_PLAINTEXT:
                log.debug('OTR conversation with %s finished', self.peer)
                if tab:
mathieui's avatar
mathieui committed
383
                    tab.add_message(OTR_END % format_dict, typ=self.log)
384
                    hl(tab)
mathieui's avatar
mathieui committed
385 386
        elif newstate == STATE_ENCRYPTED and tab:
            if self.getCurrentTrust():
mathieui's avatar
mathieui committed
387
                tab.add_message(OTR_START_TRUSTED % format_dict, typ=self.log)
mathieui's avatar
mathieui committed
388
            else:
mathieui's avatar
mathieui committed
389 390 391 392
                format_dict['our_fpr'] = self.user.getPrivkey()
                format_dict['remote_fpr'] = self.getCurrentKey()
                tab.add_message(OTR_TUTORIAL % format_dict, typ=0)
                tab.add_message(OTR_START_UNTRUSTED % format_dict, typ=self.log)
mathieui's avatar
mathieui committed
393
            hl(tab)
394 395 396 397 398 399 400 401

        log.debug('Set encryption state of %s to %s', self.peer, states[newstate])
        super(PoezioContext, self).setState(newstate)
        if tab:
            self.core.refresh_window()
            self.core.doupdate()

class PoezioAccount(Account):
mathieui's avatar
mathieui committed
402 403 404 405 406
    """
    OTR Account, keeps track of a specific account (ours)

    Redefines the load/save methods from potr.context.Account
    """
407 408

    def __init__(self, jid, key_dir):
409
        super(PoezioAccount, self).__init__(jid, 'xmpp', 0)
410 411 412 413 414 415 416 417 418
        self.key_dir = os.path.join(key_dir, jid)

    def load_privkey(self):
        try:
            with open(self.key_dir + '.key3', 'rb') as keyfile:
                return potr.crypt.PK.parsePrivateKey(keyfile.read())[0]
        except:
            log.error('Error in load_privkey', exc_info=True)

419 420 421 422 423 424 425
    def drop_privkey(self):
        try:
            os.remove(self.key_dir + '.key3')
        except:
            log.exception('Error in drop_privkey (removing %s)', self.key_dir + '.key3')
        self.privkey = None

426 427 428 429 430 431 432
    def save_privkey(self):
        try:
            with open(self.key_dir + '.key3', 'xb') as keyfile:
                keyfile.write(self.getPrivkey().serializePrivateKey())
        except:
            log.error('Error in save_privkey', exc_info=True)

433 434 435 436 437 438 439 440 441 442 443 444 445 446 447
    def load_trusts(self):
        try:
            with open(self.key_dir + '.fpr', 'r') as fpr_fd:
                for line in fpr_fd:
                    ctx, acc, proto, fpr, trust = line[:-1].split('\t')

                    if acc != self.name or proto != 'xmpp':
                        continue
                    jid = safeJID(ctx).bare
                    if not jid:
                        continue
                    self.setTrust(jid, fpr, trust)
        except:
            log.error('Error in load_trusts', exc_info=True)

448
    def save_trusts(self):
449 450 451 452
        try:
            with open(self.key_dir + '.fpr', 'w') as fpr_fd:
                for uid, trusts in self.trusts.items():
                    for fpr, trustVal in trusts.items():
mathieui's avatar
mathieui committed
453
                        fpr_fd.write('\t'.join((uid, self.name, 'xmpp', fpr, trustVal)))
454 455 456
                        fpr_fd.write('\n')
        except:
            log.exception('Error in save_trusts', exc_info=True)
457 458

    saveTrusts = save_trusts
459
    loadTrusts = load_trusts
460 461 462 463
    loadPrivkey = load_privkey
    savePrivkey = save_privkey

states = {
mathieui's avatar
mathieui committed
464 465 466
    STATE_PLAINTEXT: 'plaintext',
    STATE_ENCRYPTED: 'encrypted',
    STATE_FINISHED: 'finished',
467
}
mathieui's avatar
mathieui committed
468 469

class Plugin(BasePlugin):
470

mathieui's avatar
mathieui committed
471
    def init(self):
472 473 474 475 476
        # set the default values from the config
        global OTR_DIR
        OTR_DIR = os.path.expanduser(self.config.get('keys_dir', '') or OTR_DIR)
        try:
            os.makedirs(OTR_DIR)
477 478
        except OSError as e:
            if e.errno != 17:
mathieui's avatar
mathieui committed
479 480 481
                self.api.information('The OTR-specific folder could not '
                                     'be created. Poezio will be unable '
                                     'to save keys and trusts', 'OTR')
482

483
        except:
mathieui's avatar
mathieui committed
484 485 486
            self.api.information('The OTR-specific folder could not '
                                 'be created. Poezio will be unable '
                                 'to save keys and trusts', 'OTR')
mathieui's avatar
mathieui committed
487

488 489 490 491
        self.api.add_event_handler('conversation_msg', self.on_conversation_msg)
        self.api.add_event_handler('private_msg', self.on_conversation_msg)
        self.api.add_event_handler('conversation_say_after', self.on_conversation_say)
        self.api.add_event_handler('private_say_after', self.on_conversation_say)
492

mathieui's avatar
mathieui committed
493
        ConversationTab.add_information_element('otr', self.display_encryption_status)
494
        PrivateTab.add_information_element('otr', self.display_encryption_status)
495

496 497
        self.core.xmpp.plugin['xep_0030'].add_feature('urn:xmpp:otr:0')

498
        self.account = PoezioAccount(self.core.xmpp.boundjid.bare, OTR_DIR)
499
        self.account.load_trusts()
500
        self.contexts = {}
mathieui's avatar
mathieui committed
501
        usage = '<start|refresh|end|fpr|ourfpr|drop|trust|untrust>'
mathieui's avatar
mathieui committed
502
        shortdesc = 'Manage an OTR conversation'
503 504 505 506 507 508 509 510
        desc = ('Manage an OTR conversation.\n'
                'start/refresh: Start or refresh a conversation\n'
                'end: End a conversation\n'
                'fpr: Show the fingerprint of the key of the remote user\n'
                'ourfpr: Show the fingerprint of your own key\n'
                'drop: Remove the current key (FOREVER)\n'
                'trust: Set this key for this contact as trusted\n'
                'untrust: Remove the trust for the key of this contact\n')
mathieui's avatar
mathieui committed
511 512 513 514 515 516 517 518
        smp_usage = '<abort|ask|answer> [question] [answer]'
        smp_short = 'Identify a contact'
        smp_desc = ('Verify the identify of your contact by using a pre-defined secret.\n'
                    'abort: Abort an ongoing verification\n'
                    'ask: Start a verification, with a question or not\n'
                    'answer: Finish a verification\n')

        self.api.add_tab_command(ConversationTab, 'otrsmp', self.command_smp,
mathieui's avatar
mathieui committed
519 520
                                 help=smp_desc, usage=smp_usage, short=smp_short,
                                 completion=self.completion_smp)
mathieui's avatar
mathieui committed
521
        self.api.add_tab_command(PrivateTab, 'otrsmp', self.command_smp,
mathieui's avatar
mathieui committed
522 523
                                 help=smp_desc, usage=smp_usage, short=smp_short,
                                 completion=self.completion_smp)
mathieui's avatar
mathieui committed
524

525
        self.api.add_tab_command(ConversationTab, 'otr', self.command_otr,
mathieui's avatar
mathieui committed
526 527
                                 help=desc, usage=usage, short=shortdesc,
                                 completion=self.completion_otr)
528
        self.api.add_tab_command(PrivateTab, 'otr', self.command_otr,
mathieui's avatar
mathieui committed
529 530
                                 help=desc, usage=usage, short=shortdesc,
                                 completion=self.completion_otr)
mathieui's avatar
mathieui committed
531 532

    def cleanup(self):
533 534 535
        for context in self.contexts.values():
            context.disconnect()

536 537
        self.core.xmpp.plugin['xep_0030'].del_feature(feature='urn:xmpp:otr:0')

mathieui's avatar
mathieui committed
538
        ConversationTab.remove_information_element('otr')
539 540 541
        PrivateTab.remove_information_element('otr')

    def get_context(self, jid):
mathieui's avatar
mathieui committed
542 543 544
        """
        Retrieve or create an OTR context
        """
545 546
        jid = safeJID(jid)
        if not jid.full in self.contexts:
547
            flags = POLICY_FLAGS.copy()
mathieui's avatar
mathieui committed
548
            require = self.config.get_by_tabname('require_encryption',
549
                                                 jid.bare, default=False)
mathieui's avatar
mathieui committed
550
            flags['REQUIRE_ENCRYPTION'] = require
551 552 553 554 555
            logging_policy = self.config.get_by_tabname('log', jid.bare , default=False)
            self.contexts[jid.full] = PoezioContext(self.account, jid.full, self.core.xmpp, self.core)
            self.contexts[jid.full].log = 1 if logging_policy else 0
            self.contexts[jid.full].flags = flags
        return self.contexts[jid.full]
556 557

    def on_conversation_msg(self, msg, tab):
mathieui's avatar
mathieui committed
558 559 560 561 562 563 564 565
        """
        Message received
        """
        format_dict = {
            'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
            'info':  '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
            'jid': msg['from']
        }
566 567 568
        try:
            ctx = self.get_context(msg['from'])
            txt, tlvs = ctx.receiveMessage(msg["body"].encode('utf-8'))
mathieui's avatar
mathieui committed
569

mathieui's avatar
mathieui committed
570
            # SMP
mathieui's avatar
mathieui committed
571
            if tlvs:
mathieui's avatar
mathieui committed
572
                self.handle_tlvs(tlvs, ctx, tab, format_dict)
573 574
        except UnencryptedMessage as err:
            # received an unencrypted message inside an OTR session
mathieui's avatar
mathieui committed
575
            self.unencrypted_message_received(err, ctx, msg, tab, format_dict)
mathieui's avatar
mathieui committed
576
            self.otr_start(tab, tab.name, format_dict)
mathieui's avatar
mathieui committed
577 578 579 580 581 582 583 584
            return
        except NotOTRMessage as err:
            # ignore non-otr messages
            # if we expected an OTR message, we would have
            # got an UnencryptedMesssage
            # but do an additional check because of a bug with potr and py3k
            if ctx.state != STATE_PLAINTEXT or ctx.getPolicy('REQUIRE_ENCRYPTION'):
                self.unencrypted_message_received(err, ctx, msg, tab, format_dict)
mathieui's avatar
mathieui committed
585
                self.otr_start(tab, tab.name, format_dict)
586 587 588
            return
        except ErrorReceived as err:
            # Received an OTR error
589
            format_dict['err'] = err.args[0].error.decode('utf-8', errors='replace')
mathieui's avatar
mathieui committed
590
            tab.add_message(OTR_ERROR % format_dict, typ=0)
591
            del msg['body']
592
            del msg['html']
593
            hl(tab)
594 595 596
            self.core.refresh_window()
            return
        except NotEncryptedError as err:
mathieui's avatar
mathieui committed
597 598 599 600
            # Encrypted message received, but unreadable as we do not have
            # an OTR session in place.
            text = MESSAGE_UNREADABLE % format_dict
            tab.add_message(text, jid=msg['from'], typ=0)
601
            hl(tab)
602
            del msg['body']
603 604 605 606
            del msg['html']
            self.core.refresh_window()
            return
        except crypt.InvalidParameterError:
mathieui's avatar
mathieui committed
607 608 609
            # Malformed OTR payload and stuff
            text = MESSAGE_INVALID % format_dict
            tab.add_message(text, jid=msg['from'], typ=0)
610 611 612
            hl(tab)
            del msg['body']
            del msg['html']
613
            self.core.refresh_window()
mathieui's avatar
mathieui committed
614
            return
mathieui's avatar
mathieui committed
615 616 617 618 619 620
        except Exception:
            # Unexpected error
            import traceback
            exc = traceback.format_exc()
            format_dict['exc'] = exc
            tab.add_message(POTR_ERROR % format_dict, typ=0)
621
            log.error('Unspecified error in the OTR plugin', exc_info=True)
622
            return
mathieui's avatar
mathieui committed
623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673
        # No error, proceed with the message
        self.encrypted_message_received(msg, ctx, tab, txt)

    def handle_tlvs(self, tlvs, ctx, tab, format_dict):
        """
        If the message had a TLV, it means we received part of an SMP
        exchange.
        """
        smp1q = get_tlv(tlvs, potr.proto.SMP1QTLV)
        smp1 = get_tlv(tlvs, potr.proto.SMP1TLV)
        smp2 = get_tlv(tlvs, potr.proto.SMP2TLV)
        smp3 = get_tlv(tlvs, potr.proto.SMP3TLV)
        smp4 = get_tlv(tlvs, potr.proto.SMP4TLV)
        abort = get_tlv(tlvs, potr.proto.SMPABORTTLV)
        if abort:
            ctx.reset_smp()
            tab.add_message(SMP_ABORTED_PEER % format_dict, typ=0)
        elif ctx.in_smp and not ctx.smpIsValid():
            ctx.reset_smp()
            tab.add_message(SMP_ABORTED % format_dict, typ=0)
        elif smp1 or smp1q:
            # Received an SMP request (with a question or not)
            if smp1q:
                try:
                    question = ' with question: \x19o' + smp1q.msg.decode('utf-8')
                except UnicodeDecodeError:
                    self.api.information('The peer sent a question but it had a wrong encoding', 'Error')
                    question = ''
            else:
                question = ''
            ctx.in_smp = True
            # we did not initiate it
            ctx.smp_own = False
            format_dict['q'] = question
            tab.add_message(SMP_REQUESTED % format_dict, typ=0)
        elif smp2:
            # SMP reply received
            if not ctx.in_smp:
                ctx.reset_smp()
            else:
                tab.add_message(SMP_PROGRESS % format_dict, typ=0)
        elif smp3 or smp4:
            # Type 4 (SMP message 3) or 5 (SMP message 4) TLVs received
            # in both cases it is the final message of the SMP exchange
            if ctx.smpIsSuccess():
                tab.add_message(SMP_SUCCESS % format_dict, typ=0)
                if not ctx.getCurrentTrust():
                    tab.add_message(SMP_RECIPROCATE % format_dict, typ=0)
            else:
                tab.add_message(SMP_FAIL % format_dict, typ=0)
            ctx.reset_smp()
mathieui's avatar
mathieui committed
674
        hl(tab)
mathieui's avatar
mathieui committed
675
        self.core.refresh_window()
676

mathieui's avatar
mathieui committed
677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694
    def unencrypted_message_received(self, err, ctx, msg, tab, format_dict):
        """
        An unencrypted message was received while we expected it to be
        encrypted. Display it with a warning.
        """
        format_dict['msg'] = err.args[0].decode('utf-8')
        text = MESSAGE_UNENCRYPTED % format_dict
        tab.add_message(text, jid=msg['from'], typ=ctx.log)
        del msg['body']
        del msg['html']
        hl(tab)
        self.core.refresh_window()

    def encrypted_message_received(self, msg, ctx, tab, txt):
        """
        A properly encrypted message was received, so we add it to the
        buffer, and try to format it according to the configuration.
        """
695 696 697 698 699 700 701 702
        # remove xhtml
        del msg['html']
        del msg['body']

        if not txt:
            return
        if isinstance(tab, PrivateTab):
            user = tab.parent_muc.get_user_by_name(msg['from'].resource)
703
            nick_color = None
mathieui's avatar
mathieui committed
704
        else:
705
            user = None
706
            nick_color = get_theme().COLOR_REMOTE_USER
707 708

        body = txt.decode()
709 710
        decode_entities = self.config.get_by_tabname('decode_entities',
                                                     msg['from'].bare,
711 712 713 714
                                                     default=True)
        decode_newlines = self.config.get_by_tabname('decode_newlines',
                                                     msg['from'].bare,
                                                     default=True)
715
        if self.config.get_by_tabname('decode_xhtml', msg['from'].bare, default=True):
716 717
            try:
                body = xhtml.xhtml_to_poezio_colors(body, force=True)
718
            except Exception:
719 720
                if decode_entities:
                    body = html.unescape(body)
721 722 723 724 725 726 727
                if decode_newlines:
                    body = body.replace('<br/>', '\n').replace('<br>', '\n')
        else:
            if decode_entities:
                body = html.unescape(body)
            if decode_newlines:
                body = body.replace('<br/>', '\n').replace('<br>', '\n')
728
        tab.add_message(body, nickname=tab.nick, jid=msg['from'],
729
                        forced_user=user, typ=ctx.log,
730
                        nick_color=nick_color)
731
        hl(tab)
732 733
        self.core.refresh_window()
        del msg['body']
mathieui's avatar
mathieui committed
734

735
    def find_encrypted_context_with_matching(self, bare_jid):
mathieui's avatar
mathieui committed
736 737 738 739 740 741 742
        """
        Find an OTR session from a bare JID.

        Useful when a dynamic tab unlocks, which would lead to sending
        unencrypted messages until it locks again, if we didn’t fallback
        with this.
        """
743 744 745 746 747
        for ctx in self.contexts:
            if safeJID(ctx).bare == bare_jid and self.contexts[ctx].state == STATE_ENCRYPTED:
                return self.contexts[ctx]
        return None

748
    def on_conversation_say(self, msg, tab):
mathieui's avatar
mathieui committed
749
        """
750
        On message sent
mathieui's avatar
mathieui committed
751
        """
752
        if isinstance(tab, DynamicConversationTab) and tab.locked_resource:
753 754 755
            jid = safeJID(tab.name)
            jid.resource = tab.locked_resource
            name = jid.full
mathieui's avatar
mathieui committed
756
        else:
757
            name = tab.name
758 759
            jid = safeJID(tab.name)

mathieui's avatar
mathieui committed
760 761 762 763 764 765
        format_dict = {
            'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
            'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
            'jid': name,
        }

766 767 768
        ctx = None
        default_ctx = self.get_context(name)

769 770 771 772
        if isinstance(tab, DynamicConversationTab) and not tab.locked_resource:
            log.debug('Unlocked tab %s found, falling back to the first encrypted chat we find.', name)
            ctx = self.find_encrypted_context_with_matching(jid.bare)

773 774 775
        if ctx is None:
            ctx = default_ctx

776 777
        if ctx and ctx.state == STATE_ENCRYPTED:
            ctx.sendMessage(0, msg['body'].encode('utf-8'))
778 779 780
            if not tab.send_chat_state('active'):
                tab.send_chat_state('inactive', always_send=True)

781
            tab.add_message(msg['body'],
mathieui's avatar
mathieui committed
782 783 784 785 786
                            nickname=self.core.own_nick or tab.own_nick,
                            nick_color=get_theme().COLOR_OWN_NICK,
                            identifier=msg['id'],
                            jid=self.core.xmpp.boundjid,
                            typ=ctx.log)
787 788 789 790
            # remove everything from the message so that it doesn’t get sent
            del msg['body']
            del msg['replace']
            del msg['html']
mathieui's avatar
mathieui committed
791
        elif ctx and ctx.getPolicy('REQUIRE_ENCRYPTION'):
mathieui's avatar
mathieui committed
792 793 794 795
            tab.add_message(MESSAGE_NOT_SENT % format_dict, typ=0)
            del msg['body']
            del msg['replace']
            del msg['html']
mathieui's avatar
mathieui committed
796
            self.otr_start(tab, name, format_dict)
mathieui's avatar
mathieui committed
797 798

    def display_encryption_status(self, jid):
mathieui's avatar
mathieui committed
799 800 801
        """
        Returns the text to display in the infobar (the OTR status)
        """
802
        context = self.get_context(jid)
803 804 805 806
        if safeJID(jid).bare == jid and context.state != STATE_ENCRYPTED:
            ctx = self.find_encrypted_context_with_matching(jid)
            if ctx:
                context = ctx
807
        state = states[context.state]
mathieui's avatar
mathieui committed
808 809 810
        trust = 'trusted' if context.getCurrentTrust() else 'untrusted'

        return ' OTR: %s (%s)' % (state, trust)
811 812

    def command_otr(self, arg):
mathieui's avatar
mathieui committed
813
        """
814
        /otr [start|refresh|end|fpr|ourfpr]
mathieui's avatar
mathieui committed
815
        """
mathieui's avatar
mathieui committed
816 817
        args = common.shell_split(arg)
        if not args:
818
            return self.core.command.help('otr')
mathieui's avatar
mathieui committed
819
        action = args.pop(0)
820 821 822 823 824 825
        tab = self.api.current_tab()
        name = tab.name
        if isinstance(tab, DynamicConversationTab) and tab.locked_resource:
            name = safeJID(tab.name)
            name.resource = tab.locked_resource
            name = name.full
mathieui's avatar
mathieui committed
826 827 828 829 830 831 832 833
        format_dict = {
            'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
            'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
            'normal': '\x19%s}' % dump_tuple(get_theme().COLOR_NORMAL_TEXT),
            'jid': name,
            'bare_jid': safeJID(name).bare
        }

mathieui's avatar
mathieui committed
834
        if action == 'end': # close the session
835 836
            context = self.get_context(name)
            context.disconnect()
837
            if isinstance(tab, DynamicConversationTab):
838
                ctx = self.find_encrypted_context_with_matching(safeJID(name).bare)
839
                while ctx is not None:
mathieui's avatar
mathieui committed
840
                    ctx.disconnect()
841
                    ctx = self.find_encrypted_context_with_matching(safeJID(name).bare)
mathieui's avatar
mathieui committed
842
        elif action == 'start' or action == 'refresh':
mathieui's avatar
mathieui committed
843
            self.otr_start(tab, name, format_dict)
mathieui's avatar
mathieui committed
844
        elif action == 'ourfpr':
mathieui's avatar
mathieui committed
845 846
            format_dict['fpr'] = self.account.getPrivkey()
            tab.add_message(OTR_OWN_FPR % format_dict, typ=0)
mathieui's avatar
mathieui committed
847
        elif action == 'fpr':
848 849
            if name in self.contexts:
                ctx = self.contexts[name]
850
                if ctx.getCurrentKey() is not None:
mathieui's avatar
mathieui committed
851 852
                    format_dict['fpr'] = ctx.getCurrentKey()
                    tab.add_message(OTR_REMOTE_FPR % format_dict, typ=0)
853
                else:
mathieui's avatar
mathieui committed
854
                    tab.add_message(OTR_NO_FPR % format_dict, typ=0)
mathieui's avatar
mathieui committed
855
        elif action == 'drop':
856 857 858 859 860
            # drop the privkey (and obviously, end the current conversations before that)
            for context in self.contexts.values():
                if context.state not in (STATE_FINISHED, STATE_PLAINTEXT):
                    context.disconnect()
            self.account.drop_privkey()
mathieui's avatar
mathieui committed
861
            tab.add_message(KEY_DROPPED % format_dict, typ=0)
mathieui's avatar
mathieui committed
862
        elif action == 'trust':
863 864 865 866 867 868
            ctx = self.get_context(name)
            key = ctx.getCurrentKey()
            if key:
                fpr = key.cfingerprint()
            else:
                return
869
            if not ctx.getCurrentTrust():
mathieui's avatar
mathieui committed
870
                format_dict['key'] = key
871 872
                ctx.setTrust(fpr, 'verified')
                self.account.saveTrusts()
mathieui's avatar
mathieui committed
873
                tab.add_message(TRUST_ADDED % format_dict, typ=0)
mathieui's avatar
mathieui committed
874
        elif action == 'untrust':
875 876 877 878 879 880
            ctx = self.get_context(name)
            key = ctx.getCurrentKey()
            if key:
                fpr = key.cfingerprint()
            else:
                return
881
            if ctx.getCurrentTrust():
mathieui's avatar
mathieui committed
882
                format_dict['key'] = key
883 884
                ctx.setTrust(fpr, '')
                self.account.saveTrusts()
mathieui's avatar
mathieui committed
885
                tab.add_message(TRUST_REMOVED % format_dict, typ=0)
886
        self.core.refresh_window()
887

mathieui's avatar
mathieui committed
888 889 890 891 892 893
    def otr_start(self, tab, name, format_dict):
        """
        Start an otr conversation with a contact
        """
        secs = self.config.get('timeout', 3)
        def notify_otr_timeout():
mathieui's avatar
mathieui committed
894 895 896
            tab_name = tab.name
            otr = self.get_context(tab_name)
            if isinstance(tab, DynamicConversationTab):
mathieui's avatar
mathieui committed
897
                if tab.locked_resource:
mathieui's avatar
mathieui committed
898 899 900 901
                    tab_name = safeJID(tab.name)
                    tab_name.resource = tab.locked_resource
                    tab_name = tab_name.full
                    otr = self.get_context(tab_name)
mathieui's avatar
mathieui committed
902 903 904 905 906 907 908 909
            if otr.state != STATE_ENCRYPTED:
                format_dict['secs'] = secs
                text = OTR_NOT_ENABLED % format_dict
                tab.add_message(text, typ=0)
                self.core.refresh_window()
        if secs > 0:
            event = self.api.create_delayed_event(secs, notify_otr_timeout)
            self.api.add_timed_event(event)
910
        body = self.get_context(name).sendMessage(0, b'?OTRv?').decode()
mathieui's avatar
mathieui committed
911 912 913
        self.core.xmpp.send_message(mto=name, mtype='chat', mbody=body)
        tab.add_message(OTR_REQUEST % format_dict, typ=0)

mathieui's avatar
mathieui committed
914 915 916 917 918
    @staticmethod
    def completion_otr(the_input):
        """
        Completion for /otr
        """
919
        comp = ['start', 'fpr', 'ourfpr', 'refresh', 'end', 'trust', 'untrust']
920
        return Completion(the_input.new_completion, comp, 1, quotify=False)
mathieui's avatar
mathieui committed
921

mathieui's avatar
mathieui committed
922 923 924 925 926 927
    @command_args_parser.quoted(1, 2)
    def command_smp(self, args):
        """
        /otrsmp <ask|answer|abort> [question] [secret]
        """
        if args is None or not args:
928
            return self.core.command.help('otrsmp')
mathieui's avatar
mathieui committed
929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946
        length = len(args)
        action = args.pop(0)
        if length == 2:
            question = None
            secret = args.pop(0).encode('utf-8')
        elif length == 3:
            question = args.pop(0).encode('utf-8')
            secret = args.pop(0).encode('utf-8')
        else:
            question = secret = None

        tab = self.api.current_tab()
        name = tab.name
        if isinstance(tab, DynamicConversationTab) and tab.locked_resource:
            name = safeJID(tab.name)
            name.resource = tab.locked_resource
            name = name.full

mathieui's avatar
mathieui committed
947 948 949 950 951 952 953
        format_dict = {
            'jid_c': '\x19%s}' % dump_tuple(get_theme().COLOR_MUC_JID),
            'info': '\x19%s}' % dump_tuple(get_theme().COLOR_INFORMATION_TEXT),
            'jid': name,
            'bare_jid': safeJID(name).bare
        }

mathieui's avatar
mathieui committed
954 955
        ctx = self.get_context(name)
        if ctx.state != STATE_ENCRYPTED:
mathieui's avatar
mathieui committed
956 957 958
            self.api.information('The current conversation is not encrypted',
                                 'Error')
            return
mathieui's avatar
mathieui committed
959 960 961 962 963 964 965 966

        if action == 'ask':
            ctx.in_smp = True
            ctx.smp_own = True
            if question:
                ctx.smpInit(secret, question)
            else:
                ctx.smpInit(secret)
mathieui's avatar
mathieui committed
967
            tab.add_message(SMP_INITIATED % format_dict, typ=0)
mathieui's avatar
mathieui committed
968 969 970 971 972
        elif action == 'answer':
            ctx.smpGotSecret(secret)
        elif action == 'abort':
            if ctx.in_smp:
                ctx.smpAbort()
mathieui's avatar
mathieui committed
973
                tab.add_message(SMP_ABORTED % format_dict, typ=0)
mathieui's avatar
mathieui committed
974 975
        self.core.refresh_window()

mathieui's avatar
mathieui committed
976 977 978
    @staticmethod
    def completion_smp(the_input):
        """Completion for /otrsmp"""
mathieui's avatar
mathieui committed
979
        if the_input.get_argument_position() == 1:
980
            return Completion(the_input.new_completion, ['ask', 'answer', 'abort'], 1, quotify=False)
mathieui's avatar
mathieui committed
981 982

def get_tlv(tlvs, cls):
mathieui's avatar
mathieui committed
983
    """Find the instance of a class in a list"""
mathieui's avatar
mathieui committed
984 985 986
    for tlv in tlvs:
        if isinstance(tlv, cls):
            return tlv