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

This plugin implements `Off The Record messaging`_.

This is a plugin used to encrypt one-to-one conversation using the OTR
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
19 20 21 22 23 24
**not** send XHTML-IM messages to him (or correct messages, or anything more than
raw text). They will be removed and be replaced by plain text messages.

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

29
To use the OTR plugin, you must first install pure-python-otr.
mathieui's avatar
mathieui committed
30

31 32 33
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
34

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

.. code-block:: bash

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

43 44
You can also use pip with the requirements.txt at the root of
the poezio directory.
mathieui's avatar
mathieui committed
45 46


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

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

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

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

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

59 60
        - The ``start`` (or ``refresh``) command starts or refreshs a private OTR session
        - The ``end`` command ends a private OTR session
61
        - The ``fpr`` command gives you the fingerprint of the key of the remote entity
62 63 64
        - 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
65 66 67 68 69 70
        - Finally, the ``drop`` command is used if you want to delete your private key (not recoverable).

        .. warning::

            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
71 72 73 74


To use OTR, make sure the plugin is loaded (if not, then do ``/load otr``).

75
A simple workflow looks like this:
mathieui's avatar
mathieui committed
76 77 78 79 80 81 82 83

.. code-block:: none

    /otr start

The status of the OTR encryption should appear in the bar between the chat and
the input as ``OTR: encrypted``.

84 85 86 87 88 89 90 91 92 93 94 95 96
Then you use ``fpr``/``ourfpr`` to check the fingerprints, and confirm your respective
identities out-of-band.

You can then use

.. code-block:: none

    /otr trust

To set the key as trusted, which will be shown when you start or refresh a conversation
(the trust status will be in a bold font and if the key is untrusted, the remote fingerprint
will be shown).

mathieui's avatar
mathieui committed
97 98 99 100 101 102
Once you’re done, end the OTR session with

.. code-block:: none

    /otr end

103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138
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:

    keys_dir
        **Default:** ``$XDG_DATA_HOME/poezio/otr``

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

    allow_v2
        **Default:** ``true``

        Allow OTRv2

    allow_v1
        **Default:** ``false``

        Allow OTRv1

139 140 141 142 143 144 145
    log
        **Default:** false

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

The :term:`allow_v1`, :term:`allow_v2` and :term:`log` configuration
parameters are tab-specific.
mathieui's avatar
mathieui committed
146

147 148
Important details
-----------------
mathieui's avatar
mathieui committed
149

150 151 152 153
The OTR session is considered for a full jid, but the trust is considered
with a bare JID. This is important to know in the case of Private Chats, since
you cannot always get the real the JID of your contact (or check if the same
nick is used by different people).
mathieui's avatar
mathieui committed
154

155
.. _Off The Record messaging: http://wiki.xmpp.org/web/OTR
mathieui's avatar
mathieui committed
156

157 158 159
"""
import potr
import theming
mathieui's avatar
mathieui committed
160
import logging
161

mathieui's avatar
mathieui committed
162
log = logging.getLogger(__name__)
163
import os
164 165
import curses

166 167
from potr.context import NotEncryptedError, UnencryptedMessage, ErrorReceived, NotOTRMessage,\
        STATE_ENCRYPTED, STATE_PLAINTEXT, STATE_FINISHED, Context, Account, crypt
mathieui's avatar
mathieui committed
168 169

from plugin import BasePlugin
170 171
from tabs import ConversationTab, DynamicConversationTab, PrivateTab
from common import safeJID
172
from config import config
173 174 175

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

177 178 179 180 181 182 183 184 185 186 187
POLICY_FLAGS = {
        'ALLOW_V1':False,
        'ALLOW_V2':True,
        'REQUIRE_ENCRYPTION': False,
        'SEND_TAG': True,
        'WHITESPACE_START_AKE': True,
        'ERROR_START_AKE': True
}

log = logging.getLogger(__name__)

188 189 190 191 192 193 194 195 196 197

def hl(tab):
    if tab.state != 'current':
        tab.state = 'private'

    conv_jid = safeJID(tab.name)
    if 'private' in config.get('beep_on', 'highlight private').split():
        if config.get_by_tabname('disable_beep', 'false', conv_jid.bare, False).lower() != 'true':
            curses.beep()

198 199 200 201 202 203
class PoezioContext(Context):
    def __init__(self, account, peer, xmpp, core):
        super(PoezioContext, self).__init__(account, peer)
        self.xmpp = xmpp
        self.core = core
        self.flags = {}
204
        self.trustName = safeJID(peer).bare
205 206 207 208 209 210 211 212

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

    def inject(self, msg, appdata=None):
213 214 215
        message = self.xmpp.make_message(mto=self.peer, mbody=msg.decode('ascii'), mtype='chat')
        message.enable('carbon_private')
        message.send()
216 217 218 219 220 221 222 223 224 225 226

    def setState(self, newstate):
        tab = self.core.get_tab_by_name(self.peer)
        if not tab:
            tab = self.core.get_tab_by_name(safeJID(self.peer).bare, DynamicConversationTab)
            if not tab.locked_resource == safeJID(self.peer).resource:
                tab = None
        if self.state == STATE_ENCRYPTED:
            if newstate == STATE_ENCRYPTED:
                log.debug('OTR conversation with %s refreshed', self.peer)
                if tab:
227
                    if self.getCurrentTrust():
228
                        tab.add_message('Refreshed \x19btrusted\x19o OTR conversation with %s' % self.peer, typ=self.log)
229 230
                    else:
                        tab.add_message('Refreshed \x19buntrusted\x19o OTR conversation with %s (key: %s)' %
231
                                (self.peer, self.getCurrentKey()), typ=self.log)
232
                    hl(tab)
233 234 235
            elif newstate == STATE_FINISHED or newstate == STATE_PLAINTEXT:
                log.debug('OTR conversation with %s finished', self.peer)
                if tab:
236
                    tab.add_message('Ended OTR conversation with %s' % self.peer, typ=self.log)
237
                    hl(tab)
238 239 240
        else:
            if newstate == STATE_ENCRYPTED:
                if tab:
241
                    if self.getCurrentTrust():
242
                        tab.add_message('Started \x19btrusted\x19o OTR conversation with %s' % self.peer, typ=self.log)
243 244
                    else:
                        tab.add_message('Started \x19buntrusted\x19o OTR conversation with %s (key: %s)' %
245
                                (self.peer, self.getCurrentKey()), typ=self.log)
246
                    hl(tab)
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266

        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):

    def __init__(self, jid, key_dir):
        super(PoezioAccount, self).__init__(jid, 'xmpp', 1024)
        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)

267 268 269 270 271 272 273
    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

274 275 276 277 278 279 280
    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)

281 282 283 284 285 286 287 288 289 290 291 292 293 294 295
    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)

296
    def save_trusts(self):
297 298 299 300 301 302 303 304 305
        try:
            with open(self.key_dir + '.fpr', 'w') as fpr_fd:
                for uid, trusts in self.trusts.items():
                    for fpr, trustVal in trusts.items():
                        fpr_fd.write('\t'.join(
                                (uid, self.name, 'xmpp', fpr, trustVal)))
                        fpr_fd.write('\n')
        except:
            log.exception('Error in save_trusts', exc_info=True)
306 307

    saveTrusts = save_trusts
308
    loadTrusts = load_trusts
309 310 311 312 313 314 315 316
    loadPrivkey = load_privkey
    savePrivkey = save_privkey

states = {
        STATE_PLAINTEXT: 'plaintext',
        STATE_ENCRYPTED: 'encrypted',
        STATE_FINISHED: 'finished',
}
mathieui's avatar
mathieui committed
317 318

class Plugin(BasePlugin):
319

mathieui's avatar
mathieui committed
320
    def init(self):
321 322 323 324 325 326 327 328 329 330
        # set the default values from the config
        allow_v2 = self.config.get('allow_v2', 'true').lower()
        POLICY_FLAGS['ALLOW_V2'] = (allow_v2 != 'false')
        allow_v1 = self.config.get('allow_v1', 'false').lower()
        POLICY_FLAGS['ALLOW_v1'] = (allow_v1 == 'true')

        global OTR_DIR
        OTR_DIR = os.path.expanduser(self.config.get('keys_dir', '') or OTR_DIR)
        try:
            os.makedirs(OTR_DIR)
331 332 333 334 335
        except OSError as e:
            if e.errno != 17:
                self.api.information('The OTR-specific folder could not be created'
                        ' poezio will be unable to save keys and trusts', 'OTR')

336 337 338
        except:
            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
339

340 341 342 343
        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)
344

mathieui's avatar
mathieui committed
345
        ConversationTab.add_information_element('otr', self.display_encryption_status)
346
        PrivateTab.add_information_element('otr', self.display_encryption_status)
347

348
        self.account = PoezioAccount(self.core.xmpp.boundjid.bare, OTR_DIR)
349
        self.account.load_trusts()
350
        self.contexts = {}
351
        usage = '[start|refresh|end|fpr|ourfpr|drop|trust|untrust]'
mathieui's avatar
mathieui committed
352
        shortdesc = 'Manage an OTR conversation'
353 354 355 356 357 358 359 360
        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')
361
        self.api.add_tab_command(ConversationTab, 'otr', self.command_otr,
mathieui's avatar
mathieui committed
362 363 364
                help=desc,
                usage=usage,
                short=shortdesc,
365 366
                completion=self.completion_otr)
        self.api.add_tab_command(PrivateTab, 'otr', self.command_otr,
mathieui's avatar
mathieui committed
367 368 369
                help=desc,
                usage=usage,
                short=shortdesc,
370
                completion=self.completion_otr)
mathieui's avatar
mathieui committed
371 372 373

    def cleanup(self):
        ConversationTab.remove_information_element('otr')
374 375 376 377 378 379 380
        PrivateTab.remove_information_element('otr')

    def get_context(self, jid):
        jid = safeJID(jid).full
        if not jid in self.contexts:
            flags = POLICY_FLAGS.copy()
            policy = self.config.get_by_tabname('encryption_policy', 'ondemand', jid).lower()
381
            logging_policy = self.config.get_by_tabname('log', 'false', jid).lower()
382 383 384 385 386
            allow_v2 = self.config.get_by_tabname('allow_v2', 'true', jid).lower()
            flags['ALLOW_V2'] = (allow_v2 != 'false')
            allow_v1 = self.config.get_by_tabname('allow_v1', 'false', jid).lower()
            flags['ALLOW_V1'] = (allow_v1 == 'true')
            self.contexts[jid] = PoezioContext(self.account, jid, self.core.xmpp, self.core)
387
            self.contexts[jid].log = 1 if logging_policy != 'false' else 0
388 389 390 391 392 393 394 395 396 397 398 399 400
            self.contexts[jid].flags = flags
        return self.contexts[jid]

    def on_conversation_msg(self, msg, tab):
        try:
            ctx = self.get_context(msg['from'])
            txt, tlvs = ctx.receiveMessage(msg["body"].encode('utf-8'))
        except UnencryptedMessage as err:
            # received an unencrypted message inside an OTR session
            tab.add_message('The following message from %s was not encrypted:\n%s' % (msg['from'], err.args[0].decode('utf-8')),
                    jid=msg['from'], nick_color=theming.get_theme().COLOR_REMOTE_USER,
                    typ=0)
            del msg['body']
401
            del msg['html']
402
            hl(tab)
403 404 405 406
            self.core.refresh_window()
            return
        except ErrorReceived as err:
            # Received an OTR error
407
            tab.add_message('Received the following error from %s: %s' % (msg['from'], err.args[0]), typ=0)
408
            del msg['body']
409
            del msg['html']
410
            hl(tab)
411 412 413 414 415 416
            self.core.refresh_window()
            return
        except NotOTRMessage as err:
            # ignore non-otr messages
            # if we expected an OTR message, we would have
            # got an UnencryptedMesssage
417 418 419 420 421
            # but do an additional check because of a bug with py3k
            if ctx.state != STATE_PLAINTEXT or ctx.getPolicy('REQUIRE_ENCRYPTION'):

                tab.add_message('The following message from %s was not encrypted:\n%s' % (msg['from'], err.args[0].decode('utf-8')),
                        jid=msg['from'], nick_color=theming.get_theme().COLOR_REMOTE_USER,
422
                        typ=ctx.log)
423 424 425 426 427
                del msg['body']
                del msg['html']
                hl(tab)
                self.core.refresh_window()
                return
428 429
            return
        except NotEncryptedError as err:
430 431 432 433
            tab.add_message('An encrypted message from %s was received but is '
                    'unreadable, as you are not currently communicating'
                    ' privately.' % msg['from'], jid=msg['from'],
                    nick_color=theming.get_theme().COLOR_REMOTE_USER,
434
                    typ=0)
435
            hl(tab)
436
            del msg['body']
437 438 439 440 441 442 443 444 445 446
            del msg['html']
            self.core.refresh_window()
            return
        except crypt.InvalidParameterError:
            tab.add_message('The message from %s could not be decrypted' %
                    msg['from'], jid=msg['from'], typ=0,
                    nick_color=theming.get_theme().COLOR_REMOTE_USER)
            hl(tab)
            del msg['body']
            del msg['html']
447
            self.core.refresh_window()
mathieui's avatar
mathieui committed
448
            return
449
        except:
450
            tab.add_message('An unspecified error in the OTR plugin occured', typ=0)
451
            log.error('Unspecified error in the OTR plugin', exc_info=True)
452 453 454 455 456 457 458 459 460 461
            return

        # 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)
mathieui's avatar
mathieui committed
462
        else:
463 464 465 466
            user = None

        body = txt.decode()
        tab.add_message(body, nickname=tab.nick, jid=msg['from'],
467
                forced_user=user, typ=ctx.log, nick_color=theming.get_theme().COLOR_REMOTE_USER)
468
        hl(tab)
469 470
        self.core.refresh_window()
        del msg['body']
mathieui's avatar
mathieui committed
471

472
    def on_conversation_say(self, msg, tab):
mathieui's avatar
mathieui committed
473
        """
474
        On message sent
mathieui's avatar
mathieui committed
475
        """
476 477 478 479
        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
480
        else:
481 482 483 484
            name = tab.name
        ctx = self.contexts.get(name)
        if ctx and ctx.state == STATE_ENCRYPTED:
            ctx.sendMessage(0, msg['body'].encode('utf-8'))
485 486 487 488 489
            tab.add_message(msg['body'],
                    nickname=self.core.own_nick or tab.own_nick,
                    nick_color=theming.get_theme().COLOR_OWN_NICK,
                    identifier=msg['id'],
                    jid=self.core.xmpp.boundjid,
490
                    typ=ctx.log)
491 492 493 494
            # 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
495 496

    def display_encryption_status(self, jid):
497 498 499 500 501
        context = self.get_context(jid)
        state = states[context.state]
        return ' OTR: %s' % state

    def command_otr(self, arg):
mathieui's avatar
mathieui committed
502
        """
503
        /otr [start|refresh|end|fpr|ourfpr]
mathieui's avatar
mathieui committed
504
        """
505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522
        arg = arg.strip()
        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
        if arg == 'end': # close the session
            context = self.get_context(name)
            context.disconnect()
        elif arg == 'start' or arg == 'refresh':
            otr = self.get_context(name)
            self.core.xmpp.send_message(mto=name, mtype='chat',
                mbody=self.contexts[name].sendMessage(0, b'?OTRv?').decode())
        elif arg == 'ourfpr':
            fpr = self.account.getPrivkey()
            self.api.information('Your OTR key fingerprint is %s' % fpr, 'OTR')
        elif arg == 'fpr':
523 524
            if name in self.contexts:
                ctx = self.contexts[name]
525
                self.api.information('The key fingerprint for %s is %s' % (name, ctx.getCurrentKey()) , 'OTR')
526 527 528 529 530 531
        elif arg == 'drop':
            # 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()
532
            tab.add_message('Private key dropped.', typ=0)
533 534 535 536 537 538 539
        elif arg == 'trust':
            ctx = self.get_context(name)
            key = ctx.getCurrentKey()
            if key:
                fpr = key.cfingerprint()
            else:
                return
540 541 542 543
            if not ctx.getCurrentTrust():
                ctx.setTrust(fpr, 'verified')
                self.account.saveTrusts()
                tab.add_message('You added \x19b%s\x19o with key %s to your trusted list.' % (ctx.trustName, key), typ=0)
544 545 546 547 548 549 550
        elif arg == 'untrust':
            ctx = self.get_context(name)
            key = ctx.getCurrentKey()
            if key:
                fpr = key.cfingerprint()
            else:
                return
551 552 553 554 555
            if ctx.getCurrentTrust():
                ctx.setTrust(fpr, '')
                self.account.saveTrusts()
                tab.add_message('You removed \x19b%s\x19o with key %s from your trusted list.' % (ctx.trustName, key), typ=0)
        self.core.refresh_window()
556 557

    def completion_otr(self, the_input):
558
        return the_input.new_completion(['start', 'fpr', 'ourfpr', 'refresh', 'end', 'trust', 'untrust'], 1, quotify=False)
mathieui's avatar
mathieui committed
559