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


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

70
A simple workflow looks like this:
mathieui's avatar
mathieui committed
71 72 73 74 75 76 77 78

.. 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``.

79 80 81 82 83 84 85 86 87 88 89 90 91
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
92 93 94 95 96 97
Once you’re done, end the OTR session with

.. code-block:: none

    /otr end

98 99 100 101 102 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
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

mathieui's avatar
mathieui committed
134
The :term:`allow_v1` and :term:`allow_v2` configuration parameters are tab-specific.
mathieui's avatar
mathieui committed
135

136 137
Important details
-----------------
mathieui's avatar
mathieui committed
138

139 140 141 142
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
143

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

146 147 148
"""
import potr
import theming
mathieui's avatar
mathieui committed
149
import logging
150

mathieui's avatar
mathieui committed
151
log = logging.getLogger(__name__)
152
import os
153 154
import curses

155 156
from potr.context import NotEncryptedError, UnencryptedMessage, ErrorReceived, NotOTRMessage,\
        STATE_ENCRYPTED, STATE_PLAINTEXT, STATE_FINISHED, Context, Account, crypt
mathieui's avatar
mathieui committed
157 158

from plugin import BasePlugin
159 160
from tabs import ConversationTab, DynamicConversationTab, PrivateTab
from common import safeJID
161
from config import config
162 163 164

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

166 167 168 169 170 171 172 173 174 175 176
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__)

177 178 179 180 181 182 183 184 185 186

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

187 188 189 190 191 192
class PoezioContext(Context):
    def __init__(self, account, peer, xmpp, core):
        super(PoezioContext, self).__init__(account, peer)
        self.xmpp = xmpp
        self.core = core
        self.flags = {}
193
        self.trustName = safeJID(peer).bare
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213

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

    def inject(self, msg, appdata=None):
        self.xmpp.send_message(mto=self.peer, mbody=msg.decode('ascii'), mtype='chat')

    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:
214 215 216 217 218
                    if self.getCurrentTrust():
                        tab.add_message('Refreshed \x19btrusted\x19o OTR conversation with %s' % self.peer)
                    else:
                        tab.add_message('Refreshed \x19buntrusted\x19o OTR conversation with %s (key: %s)' %
                                (self.peer, self.getCurrentKey()))
219
                    hl(tab)
220 221 222 223
            elif newstate == STATE_FINISHED or newstate == STATE_PLAINTEXT:
                log.debug('OTR conversation with %s finished', self.peer)
                if tab:
                    tab.add_message('Ended OTR conversation with %s' % self.peer)
224
                    hl(tab)
225 226 227
        else:
            if newstate == STATE_ENCRYPTED:
                if tab:
228 229 230 231 232
                    if self.getCurrentTrust():
                        tab.add_message('Started \x19btrusted\x19o OTR conversation with %s' % self.peer)
                    else:
                        tab.add_message('Started \x19buntrusted\x19o OTR conversation with %s (key: %s)' %
                                (self.peer, self.getCurrentKey()))
233
                    hl(tab)
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253

        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)

254 255 256 257 258 259 260
    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

261 262 263 264 265 266 267
    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)

268 269 270 271 272 273 274 275 276 277 278 279 280 281 282
    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)

283
    def save_trusts(self):
284 285 286 287 288 289 290 291 292
        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)
293 294

    saveTrusts = save_trusts
295
    loadTrusts = load_trusts
296 297 298 299 300 301 302 303
    loadPrivkey = load_privkey
    savePrivkey = save_privkey

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

class Plugin(BasePlugin):
306

mathieui's avatar
mathieui committed
307
    def init(self):
308 309 310 311 312 313 314 315 316 317
        # 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)
318 319 320 321 322
        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')

323 324 325
        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
326

327 328 329 330
        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)
331

mathieui's avatar
mathieui committed
332
        ConversationTab.add_information_element('otr', self.display_encryption_status)
333
        PrivateTab.add_information_element('otr', self.display_encryption_status)
334

335
        self.account = PoezioAccount(self.core.xmpp.boundjid.bare, OTR_DIR)
336
        self.account.load_trusts()
337
        self.contexts = {}
338
        usage = '[start|refresh|end|fpr|ourfpr|drop|trust|untrust]'
mathieui's avatar
mathieui committed
339
        shortdesc = 'Manage an OTR conversation'
340 341 342 343 344 345 346 347
        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')
348
        self.api.add_tab_command(ConversationTab, 'otr', self.command_otr,
mathieui's avatar
mathieui committed
349 350 351
                help=desc,
                usage=usage,
                short=shortdesc,
352 353
                completion=self.completion_otr)
        self.api.add_tab_command(PrivateTab, 'otr', self.command_otr,
mathieui's avatar
mathieui committed
354 355 356
                help=desc,
                usage=usage,
                short=shortdesc,
357
                completion=self.completion_otr)
mathieui's avatar
mathieui committed
358 359 360

    def cleanup(self):
        ConversationTab.remove_information_element('otr')
361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385
        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()
            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)
            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']
386
            del msg['html']
387
            hl(tab)
388 389 390 391
            self.core.refresh_window()
            return
        except ErrorReceived as err:
            # Received an OTR error
392
            tab.add_message('Received the following error from %s: %s' % (msg['from'], err.args[0]), typ=0)
393
            del msg['body']
394
            del msg['html']
395
            hl(tab)
396 397 398 399 400 401
            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
402
            log.error('coucou %s', ctx.getPolicy('REQUIRE_ENCRYPTION'))
403 404
            return
        except NotEncryptedError as err:
405 406 407 408
            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,
409
                    typ=0)
410
            hl(tab)
411
            del msg['body']
412 413 414 415 416 417 418 419 420 421
            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']
422
            self.core.refresh_window()
mathieui's avatar
mathieui committed
423
            return
424 425 426 427 428 429 430 431 432 433 434
        except:
            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
435
        else:
436 437 438 439 440
            user = None

        body = txt.decode()
        tab.add_message(body, nickname=tab.nick, jid=msg['from'],
                forced_user=user, typ=0)
441
        hl(tab)
442 443
        self.core.refresh_window()
        del msg['body']
mathieui's avatar
mathieui committed
444

445
    def on_conversation_say(self, msg, tab):
mathieui's avatar
mathieui committed
446
        """
447
        On message sent
mathieui's avatar
mathieui committed
448
        """
449 450 451 452
        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
453
        else:
454 455 456 457 458 459 460 461
            name = tab.name
        ctx = self.contexts.get(name)
        if ctx and ctx.state == STATE_ENCRYPTED:
            ctx.sendMessage(0, msg['body'].encode('utf-8'))
            # 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
462 463

    def display_encryption_status(self, jid):
464 465 466 467 468
        context = self.get_context(jid)
        state = states[context.state]
        return ' OTR: %s' % state

    def command_otr(self, arg):
mathieui's avatar
mathieui committed
469
        """
470
        /otr [start|refresh|end|fpr|ourfpr]
mathieui's avatar
mathieui committed
471
        """
472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493
        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':
            tab = self.api.current_tab()
            if tab.get_name() in self.contexts:
                ctx = self.contexts[tab.get_name()]
                self.api.information('The key fingerprint for %s is %s' % (name, ctx.getCurrentKey()) , 'OTR')
494 495 496 497 498 499
        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()
500
            tab.add_message('Private key dropped.', typ=0)
501 502 503 504 505 506 507
        elif arg == 'trust':
            ctx = self.get_context(name)
            key = ctx.getCurrentKey()
            if key:
                fpr = key.cfingerprint()
            else:
                return
508 509 510 511
            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)
512 513 514 515 516 517 518
        elif arg == 'untrust':
            ctx = self.get_context(name)
            key = ctx.getCurrentKey()
            if key:
                fpr = key.cfingerprint()
            else:
                return
519 520 521 522 523
            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()
524 525

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