├── .gitattributes ├── .gitignore ├── .travis.yml ├── .travis └── install-libsodium.sh ├── COPYING ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── make.bat └── source │ ├── changelog.rst │ ├── cli │ ├── auth-cli.png │ ├── bootstrap-cli.png │ ├── cli.rst │ ├── convs-cli.png │ ├── help-cli.png │ ├── msg-cli.png │ ├── pres-on-cli.png │ ├── req-accept-cli.png │ ├── req-send-cli.png │ └── verify-cli.png │ ├── conf.py │ ├── feedback.rst │ ├── gui │ ├── auth-gui.png │ ├── bootstrap-gui.png │ ├── gui.rst │ ├── level-auth-gui.png │ ├── level-verify-gui.png │ ├── main-gui.png │ ├── msg-gui.png │ ├── req-accept-gui.png │ ├── req-send-gui.png │ ├── start-gui.png │ └── verify-gui.png │ ├── index.rst │ ├── installation.rst │ ├── overview.rst │ └── protocol.rst ├── pytest.ini ├── requirements-dev.txt ├── requirements-docs.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── conftest.py ├── test_conversation.py ├── test_elements.py ├── test_file_transfer.py ├── test_packets.py ├── test_peer.py ├── test_serializable.py ├── test_utils.py └── utils.py ├── unmessage ├── __init__.py ├── __main__.py ├── _metadata.py ├── _version.py ├── cli.py ├── contact.py ├── elements.py ├── errors.py ├── gui.py ├── log.py ├── notifications.py ├── packets.py ├── peer.py ├── persistence.py ├── smp.py ├── ui.py ├── untalk.py └── utils.py └── versioneer.py /.gitattributes: -------------------------------------------------------------------------------- 1 | unmessage/_version.py export-subst 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is based on GitHub's collection of .gitignore templates 2 | # found at https://github.com/github/gitignore 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # Unit test / coverage reports 28 | htmlcov/ 29 | .tox/ 30 | .coverage 31 | .coverage.* 32 | .cache 33 | nosetests.xml 34 | coverage.xml 35 | *,cover 36 | .hypothesis/ 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: trusty 2 | sudo: required 3 | 4 | language: python 5 | 6 | python: 7 | - "2.7" 8 | 9 | addons: 10 | apt: 11 | packages: 12 | - build-essential 13 | - gcc 14 | - libffi-dev 15 | - libopus0 16 | - libssl-dev 17 | - portaudio19-dev 18 | - python-dev 19 | - python-tk 20 | - python-virtualenv 21 | 22 | before_install: 23 | - .travis/install-libsodium.sh 24 | - git fetch origin --tags 25 | 26 | install: 27 | - virtualenv ~/venv 28 | - . ~/venv/bin/activate 29 | - pip install -U setuptools 30 | - pip install -U pip 31 | - pip install -U pytest 32 | - pip install .[dev] 33 | 34 | script: 35 | - pytest --run-slow 36 | 37 | after_success: 38 | - codecov 39 | -------------------------------------------------------------------------------- /.travis/install-libsodium.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # script based on https://github.com/pyca/pynacl/blob/master/.travis/install.sh 3 | set -ex 4 | wget https://download.libsodium.org/libsodium/releases/LATEST.tar.gz 5 | tar zxvf LATEST.tar.gz 6 | cd libsodium-* 7 | ./configure 8 | make 9 | make check 10 | sudo make install 11 | sudo ldconfig 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include COPYING 2 | include README.rst 3 | include requirements-dev.txt 4 | include requirements-docs.txt 5 | include requirements.txt 6 | include unmessage/_version.py 7 | include versioneer.py 8 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | unMessage 2 | --------- 3 | unMessage is a peer-to-peer instant messaging application designed 4 | to enhance privacy and anonymity. 5 | 6 | Warning 7 | ''''''' 8 | unMessage is **alpha** software. While every effort has been made 9 | to make sure unMessage operates in a secure and bug-free fashion, 10 | the code has **not** been audited. Please do not use unMessage for 11 | any activity that your life depends upon. 12 | 13 | Features 14 | -------- 15 | - Transport makes use of `Twisted`_, `Tor Onion Services`_ and 16 | `txtorcon`_ 17 | 18 | - Encryption is performed using the `Double Ratchet Algorithm`_ 19 | implemented in `pyaxo`_ (using `PyNaCl`_) 20 | 21 | - Authentication makes use of the `Socialist Millionaire Protocol`_ 22 | implemented in `Cryptully`_ 23 | 24 | - Transport metadata is minimized by *Tor* and application metadata by 25 | the `unMessage protocol`_ 26 | 27 | - User interfaces are created with `Tkinter`_ (graphical) and 28 | `curses`_ (command-line) 29 | 30 | Quick Start 31 | ----------- 32 | Install the following requirements via package manager:: 33 | 34 | $ # If using Debian/Ubuntu 35 | $ sudo apt-get install build-essential gcc libffi-dev libopus0 \ 36 | libsodium-dev libssl-dev portaudio19-dev python-dev python-tk 37 | 38 | $ # If using Fedora 39 | $ sudo dnf install gcc libffi-devel libsodium-devel \ 40 | openssl-devel opus portaudio-devel python-devel \ 41 | redhat-rpm-config tkinter 42 | 43 | If you have **tor** installed, make sure its version is at least 44 | ``0.2.7.1``:: 45 | 46 | $ tor --version 47 | 48 | If you must update it or do not have it installed, check the version 49 | provided by the package manager:: 50 | 51 | $ # If using Debian/Ubuntu 52 | $ apt-cache show tor 53 | 54 | $ # If using Fedora 55 | $ dnf info tor 56 | 57 | If the version to be provided is not at least ``0.2.7.1``, you will 58 | have to `set up Tor's package repository`_. Once you have a repository 59 | which can provide an updated **tor**, install it:: 60 | 61 | $ # If using Debian/Ubuntu 62 | $ sudo apt-get install tor 63 | 64 | $ # If using Fedora 65 | $ sudo dnf install tor 66 | 67 | Installing 68 | '''''''''' 69 | Finally, using `virtualenv`_, `pip`_ and `setuptools`_ (the latter 70 | probably installed automatically with *pip*), you can easily install 71 | unMessage with:: 72 | 73 | $ virtualenv ~/unmessage-env 74 | $ . ~/unmessage-env/bin/activate 75 | (unmessage-env)$ pip install unmessage 76 | 77 | Launch unMessage with any of the commands:: 78 | 79 | (unmessage-env)$ unmessage-gui # graphical user interface (GUI) 80 | (unmessage-env)$ unmessage-cli # command-line interface (CLI) 81 | (unmessage-env)$ unmessage # last interface used 82 | 83 | Updating 84 | '''''''' 85 | If you installed unMessage with *pip*, you can also use it for 86 | updates:: 87 | 88 | (unmessage-env)$ pip install --upgrade unmessage 89 | 90 | Documentation 91 | ------------- 92 | You can find `installation`_ and usage instructions (for the `GUI`_ 93 | and the `CLI`_) on the `documentation`_. 94 | 95 | Feedback 96 | -------- 97 | Please join us on **#unMessage:anemone.me** or **#anemone:anemone.me** 98 | with `Matrix`_, **#anemone** at `OFTC`_, or use the 99 | `GitHub issue tracker`_ to leave suggestions, bug reports, complaints 100 | or anything you feel will contribute to this application. 101 | 102 | .. _`cli`: https://unmessage.readthedocs.io/en/latest/cli/cli.html 103 | .. _`cryptully`: https://github.com/shanet/Cryptully 104 | .. _`curses`: https://docs.python.org/2/library/curses.html 105 | .. _`double ratchet algorithm`: https://whispersystems.org/docs/specifications/doubleratchet 106 | .. _`documentation`: https://unmessage.readthedocs.io 107 | .. _`github issue tracker`: https://github.com/AnemoneLabs/unmessage/issues 108 | .. _`gui`: https://unmessage.readthedocs.io/en/latest/gui/gui.html 109 | .. _`set up tor's package repository`: https://www.torproject.org/docs/debian.html.en#ubuntu 110 | .. _`installation`: https://unmessage.readthedocs.io/en/latest/installation.html 111 | .. _`matrix`: https://matrix.org 112 | .. _`oftc`: https://oftc.net 113 | .. _`pip`: https://pypi.python.org/pypi/pip 114 | .. _`pyaxo`: https://github.com/rxcomm/pyaxo 115 | .. _`pynacl`: https://github.com/pyca/pynacl 116 | .. _`setuptools`: https://pypi.python.org/pypi/setuptools 117 | .. _`socialist millionaire protocol`: https://en.wikipedia.org/wiki/Socialist_millionaire 118 | .. _`tkinter`: https://docs.python.org/2/library/tkinter.html 119 | .. _`tor onion services`: https://www.torproject.org/docs/hidden-services.html 120 | .. _`twisted`: https://twistedmatrix.com 121 | .. _`txtorcon`: https://github.com/meejah/txtorcon 122 | .. _`unmessage protocol`: https://unmessage.readthedocs.io/en/latest/protocol.html 123 | .. _`virtualenv`: https://pypi.python.org/pypi/virtualenv 124 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = unMessage 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | set SPHINXPROJ=unMessage 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | unMessage 0.2.0, released 2017-05-12 6 | ==================================== 7 | 8 | - Support multiple conversation managers 9 | 10 | - Support voice conversations with unTalk 11 | 12 | - Support connecting to the system's Tor 13 | 14 | - Use Ephemeral Onion Services 15 | 16 | - Allow customization of the local server's network interface 17 | 18 | - Fix bug which allowed any command on the CLI before bootstrapping 19 | 20 | - Fix bug which ignored element packets longer than 4 lines and 21 | consequently any multiline message 22 | 23 | unMessage 0.1.1, released 2017-02-10 24 | ==================================== 25 | 26 | - Improve ports handling 27 | 28 | - Improve handling of unusual packets 29 | 30 | unMessage 0.1.0, released 2017-01-22 31 | ==================================== 32 | 33 | - Initial commit 34 | -------------------------------------------------------------------------------- /docs/source/cli/auth-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/cli/auth-cli.png -------------------------------------------------------------------------------- /docs/source/cli/bootstrap-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/cli/bootstrap-cli.png -------------------------------------------------------------------------------- /docs/source/cli/cli.rst: -------------------------------------------------------------------------------- 1 | .. _sec-cli: 2 | 3 | ============================ 4 | Command-line Interface (CLI) 5 | ============================ 6 | To launch unMessage's *CLI*, pick any name you wish to use and call 7 | it with:: 8 | 9 | $ unmessage-cli -name 10 | 11 | *Tor* is launched and if this is the first time you use that name, 12 | your *Onion Service* and *Double Ratchet* keys are created and you 13 | are ready to receive and send requests to initialize conversations. 14 | unMessage displays this bootstrap process: 15 | 16 | .. figure:: bootstrap-cli.png 17 | :figwidth: 100% 18 | :align: center 19 | 20 | Bootstrap lines 21 | 22 | After unMessage is launched, you can call ``/help`` to display all the 23 | commands the *CLI* responds to: 24 | 25 | .. figure:: help-cli.png 26 | :figwidth: 100% 27 | :align: center 28 | 29 | ``/help`` command 30 | 31 | The ``/peer``, ``/onion`` and ``/key`` commands can be used to copy 32 | information the other peers need to send you requests. You must share 33 | both your **identity address** and **key**:: 34 | 35 | bob@a7riwene46w3vqhp.onion RefK+9vx3GZpclb/On95iJ1QnxqkUeq/JBYqK5gHFwo= 36 | 37 | Sending Requests 38 | ---------------- 39 | Use the ``/req-send`` command to send a request, providing the 40 | **identity address** and **key** of the peer you wish to contact: 41 | 42 | .. figure:: req-send-cli.png 43 | :figwidth: 100% 44 | :align: center 45 | 46 | ``/req-send`` command 47 | 48 | An **identity address** is provided in the format 49 | ``@``, where the ```` is only a local 50 | identifier of the peer and you can pick any name you wish to call 51 | them. 52 | 53 | Receiving Requests 54 | ------------------ 55 | Inbound requests are notified, with the information of the peer who 56 | sent the request: 57 | 58 | .. figure:: req-accept-cli.png 59 | :figwidth: 100% 60 | :align: center 61 | 62 | ``/req-accept`` command 63 | 64 | As mentioned previously, peer names are local and when accepting a 65 | request you can pick another one to call them instead of using the one 66 | they sent. 67 | 68 | Chatting 69 | -------- 70 | unMessage diplays each peer you have a conversation with by calling 71 | the ``/convs`` command. 72 | 73 | .. figure:: convs-cli.png 74 | :figwidth: 100% 75 | :align: center 76 | 77 | ``/convs`` command 78 | 79 | To send a message to a peer, use the ``/msg`` command: 80 | 81 | .. figure:: msg-cli.png 82 | :figwidth: 100% 83 | :align: center 84 | 85 | ``/msg`` command 86 | 87 | Transmitting Files 88 | '''''''''''''''''' 89 | unMessage also supports file transfers. Sending a request to transmit 90 | a file can be done with the ``/file-send`` command:: 91 | 92 | /file-send charlie ~/file.txt 93 | 94 | The other party will receive a notification with the file name, size 95 | and checksum. It can be authorized to be transmitted with the 96 | ``/file-accept`` command:: 97 | 98 | /file-accept dory DiEjQOChrEorC0iPxrdNenBhiITaobehz5sQSkNnWIY= 99 | 100 | The file will be saved by default as 101 | ``~/.config/unMessage//conversations//file-transfer/``. 102 | The command also supports an optional argument for the path to save 103 | the file instead of using the default one:: 104 | 105 | /file-accept dory DiEjQOChrEorC0iPxrdNenBhiITaobehz5sQSkNnWIY= ~/dory.txt 106 | 107 | Once the initiator receives the confirmation, the file transfer is 108 | finally initialized and both parties are notified when it is complete. 109 | 110 | Notifying Presence 111 | '''''''''''''''''' 112 | If you wish to notify the peer whenever you go online or offline, 113 | use the ``/pres-on`` command and unMessage will start to send them 114 | notifications of these events: 115 | 116 | .. figure:: pres-on-cli.png 117 | :figwidth: 100% 118 | :align: center 119 | 120 | ``/pres-on`` command 121 | 122 | To disable, use the ``/pres-off`` command. 123 | 124 | .. _sec-cli-verifying: 125 | 126 | Verifying 127 | ''''''''' 128 | If you have some secure communication channel established with the 129 | other peer, ask them for their unMessage public identity key. Use the 130 | ``/verify`` command and enter the key: 131 | 132 | .. figure:: verify-cli.png 133 | :figwidth: 100% 134 | :align: center 135 | 136 | ``/verify`` command 137 | 138 | If the key matches, the peer will be verified and now you have 139 | established a verified and secure communication channel. 140 | 141 | .. _sec-cli-authenticating: 142 | 143 | Authenticating 144 | '''''''''''''' 145 | The authentication of a conversation works by prompting both peers for 146 | a secret (which was exchanged through some other secure channel) and 147 | if the secrets provided match, they are sure they are chatting with 148 | the right person. Call the ``/auth`` command and provide the secret: 149 | 150 | .. figure:: auth-cli.png 151 | :figwidth: 100% 152 | :align: center 153 | 154 | ``/auth`` command 155 | 156 | An authentication session is created when the secrets are exchanged 157 | and is valid until one of the peers disconnect. When it happens, the 158 | conversation is not authenticated anymore and a new session must be 159 | initialized when the peers reconnect. 160 | 161 | Assuming that one of the peers might be an attacker, this process is 162 | done with the `Socialist Millionaire Protocol`_ by comparing the 163 | secrets without actually disclosing them. 164 | 165 | Authentication Levels 166 | ''''''''''''''''''''' 167 | As noticed, the names of the peers are colored based on the 168 | conversation authentication levels: 169 | 170 | 1. Unverified Conversation (red) 171 | 2. Verified Conversation (green) 172 | 3. Authenticated Conversation (cyan) 173 | 174 | When the conversation is established, its level is 175 | **Unverified Conversation** because unMessage does not know if you 176 | are sure that the peer's identity key is actually theirs. 177 | 178 | If you follow the :ref:`sec-cli-verifying` section, the level changes 179 | to **Verified Conversation** and it persists for as long the 180 | **conversation** exists. 181 | 182 | If you follow the :ref:`sec-cli-authenticating` section, the 183 | level changes to **Authenticated Conversation** and it persists for as 184 | long the **session** exists. Once the **session** is over, the level 185 | drops to the identity key's verification level: 186 | **Unverified**/**Verified**. 187 | 188 | .. important:: 189 | 190 | The **Authenticated** level is stronger than the **Verified** 191 | level because the former is a short term verification that lasts 192 | only until the peers disconnect, while the latter is long term 193 | that lasts until the conversation is deleted (manually, by the 194 | user). That means that with a short term verification you are able 195 | to authenticate the peer at that exact time, while a long term 196 | verification means that you authenticated the peer in the past, 197 | but is not aware of a compromise in the future. 198 | 199 | This feature aims to increase unMessage's security by identifying 200 | an attack that is not covered by the scope of the 201 | *Double Ratchet Algorithm*: compromised keys. 202 | 203 | Relaunching unMessage 204 | --------------------- 205 | unMessage remembers the last User Interface and Peer that you used. If 206 | you wish to use a shortcut, you may call:: 207 | 208 | unmessage 209 | 210 | .. note:: 211 | 212 | unMessage's CLI is inspired by `xmpp-client`_. 213 | 214 | .. _`socialist millionaire protocol`: https://en.wikipedia.org/wiki/Socialist_millionaire 215 | .. _`xmpp-client`: https://github.com/agl/xmpp-client 216 | -------------------------------------------------------------------------------- /docs/source/cli/convs-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/cli/convs-cli.png -------------------------------------------------------------------------------- /docs/source/cli/help-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/cli/help-cli.png -------------------------------------------------------------------------------- /docs/source/cli/msg-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/cli/msg-cli.png -------------------------------------------------------------------------------- /docs/source/cli/pres-on-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/cli/pres-on-cli.png -------------------------------------------------------------------------------- /docs/source/cli/req-accept-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/cli/req-accept-cli.png -------------------------------------------------------------------------------- /docs/source/cli/req-send-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/cli/req-send-cli.png -------------------------------------------------------------------------------- /docs/source/cli/verify-cli.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/cli/verify-cli.png -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # unMessage documentation build configuration file, created by 4 | # sphinx-quickstart 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | import os 20 | # import sys 21 | # sys.path.insert(0, os.path.abspath('.')) 22 | 23 | # -- General configuration ------------------------------------------------ 24 | 25 | # If your documentation needs a minimal Sphinx version, state it here. 26 | # 27 | # needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = ['sphinx.ext.autodoc', 33 | 'sphinx.ext.viewcode'] 34 | 35 | # Add any paths that contain templates here, relative to this directory. 36 | templates_path = ['_templates'] 37 | 38 | # The suffix(es) of source filenames. 39 | # You can specify multiple suffix as a list of string: 40 | # 41 | # source_suffix = ['.rst', '.md'] 42 | source_suffix = '.rst' 43 | 44 | # The master toctree document. 45 | master_doc = 'index' 46 | 47 | # General information about the project. 48 | project = u'unMessage' 49 | copyright = u'2017, Anemone Labs' 50 | author = u'Anemone Labs' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | # The short X.Y version. 57 | version = '0.1.0' 58 | # The full version, including alpha/beta/rc tags. 59 | release = '0.1.0' 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | # 64 | # This is also used if you do content translation via gettext catalogs. 65 | # Usually you set "language" from the command line for these cases. 66 | language = None 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | # This patterns also effect to html_static_path and html_extra_path 71 | exclude_patterns = [] 72 | 73 | # The name of the Pygments (syntax highlighting) style to use. 74 | pygments_style = 'sphinx' 75 | 76 | # If true, `todo` and `todoList` produce output, else they produce nothing. 77 | todo_include_todos = False 78 | 79 | 80 | # -- Options for HTML output ---------------------------------------------- 81 | 82 | # The theme to use for HTML and HTML Help pages. See the documentation for 83 | # a list of builtin themes. 84 | # 85 | # html_theme = 'alabaster' 86 | 87 | # only import and set the theme if we're building docs locally 88 | if not os.environ.get('READTHEDOCS', None): 89 | import sphinx_rtd_theme 90 | html_theme = 'sphinx_rtd_theme' 91 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 92 | 93 | # Theme options are theme-specific and customize the look and feel of a theme 94 | # further. For a list of options available for each theme, see the 95 | # documentation. 96 | # 97 | # html_theme_options = {} 98 | 99 | # Add any paths that contain custom static files (such as style sheets) here, 100 | # relative to this directory. They are copied after the builtin static files, 101 | # so a file named "default.css" will overwrite the builtin "default.css". 102 | html_static_path = ['_static'] 103 | 104 | 105 | # -- Options for HTMLHelp output ------------------------------------------ 106 | 107 | # Output file base name for HTML help builder. 108 | htmlhelp_basename = 'unMessagedoc' 109 | 110 | 111 | # -- Options for LaTeX output --------------------------------------------- 112 | 113 | latex_elements = { 114 | # The paper size ('letterpaper' or 'a4paper'). 115 | # 116 | # 'papersize': 'letterpaper', 117 | 118 | # The font size ('10pt', '11pt' or '12pt'). 119 | # 120 | # 'pointsize': '10pt', 121 | 122 | # Additional stuff for the LaTeX preamble. 123 | # 124 | # 'preamble': '', 125 | 126 | # Latex figure (float) alignment 127 | # 128 | # 'figure_align': 'htbp', 129 | } 130 | 131 | # Grouping the document tree into LaTeX files. List of tuples 132 | # (source start file, target name, title, 133 | # author, documentclass [howto, manual, or own class]). 134 | latex_documents = [ 135 | (master_doc, 'unMessage.tex', u'unMessage Documentation', 136 | u'Anemone Labs', 'manual'), 137 | ] 138 | 139 | 140 | # -- Options for manual page output --------------------------------------- 141 | 142 | # One entry per manual page. List of tuples 143 | # (source start file, name, description, authors, manual section). 144 | man_pages = [ 145 | (master_doc, 'unmessage', u'unMessage Documentation', 146 | [author], 1) 147 | ] 148 | 149 | 150 | # -- Options for Texinfo output ------------------------------------------- 151 | 152 | # Grouping the document tree into Texinfo files. List of tuples 153 | # (source start file, target name, title, author, 154 | # dir menu entry, description, category) 155 | texinfo_documents = [ 156 | (master_doc, 'unMessage', u'unMessage Documentation', 157 | author, 'unMessage', 'One line description of project.', 158 | 'Miscellaneous'), 159 | ] 160 | 161 | 162 | 163 | -------------------------------------------------------------------------------- /docs/source/feedback.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Feedback 3 | ======== 4 | Please join us on **#unMessage:anemone.me** or **#anemone:anemone.me** 5 | with `Matrix`_, **#anemone** at `OFTC`_, or use the 6 | `GitHub issue tracker`_ to leave suggestions, bug reports, complaints 7 | or anything you feel will contribute to this application. 8 | 9 | .. _`github issue tracker`: https://github.com/AnemoneLabs/unmessage/issues 10 | .. _`matrix`: https://matrix.org 11 | .. _`oftc`: https://oftc.net 12 | -------------------------------------------------------------------------------- /docs/source/gui/auth-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/auth-gui.png -------------------------------------------------------------------------------- /docs/source/gui/bootstrap-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/bootstrap-gui.png -------------------------------------------------------------------------------- /docs/source/gui/gui.rst: -------------------------------------------------------------------------------- 1 | .. _sec-gui: 2 | 3 | ============================== 4 | Graphical User Interface (GUI) 5 | ============================== 6 | Launch unMessage's *GUI* with:: 7 | 8 | $ unmessage-gui 9 | 10 | You are taken to the ``Start Peer`` tab and you are required to pick any 11 | name you wish to use and press ``Start``: 12 | 13 | .. figure:: start-gui.png 14 | :figwidth: 100% 15 | :align: center 16 | 17 | Start Peer window 18 | 19 | *Tor* is launched and if this is the first time you use that name, 20 | your *Onion Service* and *Double Ratchet* keys are created and you 21 | are ready to receive and send requests to initialize conversations. 22 | unMessage displays this bootstrap process: 23 | 24 | .. figure:: bootstrap-gui.png 25 | :figwidth: 100% 26 | :align: center 27 | 28 | Bootstrap window 29 | 30 | The ``Copy`` buttons at the top bar can be used to copy information 31 | the other peers need to send you requests. You must share both your 32 | **identity address** and **key**:: 33 | 34 | charlie@jt6zabesvrhxvhee.onion:50001 v4kU6s+NuJW/Znbjz0AxoI9Gvl1XDS5eiOTm6cE38E4= 35 | 36 | Sending Requests 37 | ---------------- 38 | Press the ``New chat`` button at the top bar to open the ``Request`` 39 | window. Provide the **identity address** and **key** of the peer you 40 | wish to contact: 41 | 42 | .. figure:: req-send-gui.png 43 | :figwidth: 100% 44 | :align: center 45 | 46 | Outbound request window 47 | 48 | An **identity address** is provided in the format 49 | ``@``, where the ```` is only a local 50 | identifier of the peer and you can pick any name you wish to call 51 | them. 52 | 53 | Receiving Requests 54 | ------------------ 55 | Inbound requests are notified in a new window with the information of 56 | the peer who sent the request: 57 | 58 | .. figure:: req-accept-gui.png 59 | :figwidth: 100% 60 | :align: center 61 | 62 | Inbound request window 63 | 64 | As mentioned previously, peer names are local and when accepting a 65 | request you can pick another one to call them instead of using the one 66 | they sent. 67 | 68 | Chatting 69 | -------- 70 | unMessage creates tabs for each peer you have a conversation with. 71 | Within each tab, besides composing messages and sending (clicking 72 | ``Send`` or pressing the ``Enter`` key) there are some actions 73 | available. 74 | 75 | .. figure:: msg-gui.png 76 | :figwidth: 100% 77 | :align: center 78 | 79 | Chat tab 80 | 81 | Notifying Presence 82 | '''''''''''''''''' 83 | If you wish to notify the peer whenever you go online or offline, 84 | check ``Send Presence`` and unMessage will start to send them 85 | notifications of these events. 86 | 87 | .. _sec-gui-verifying: 88 | 89 | Verifying 90 | ''''''''' 91 | If you have some secure communication channel established with the 92 | other peer, ask them for their unMessage public identity key. Click 93 | ``Verify`` and enter the key: 94 | 95 | .. figure:: verify-gui.png 96 | :figwidth: 100% 97 | :align: center 98 | 99 | Verification window 100 | 101 | If the key matches, the peer will be verified and now you have 102 | established a verified and secure communication channel: 103 | 104 | 105 | .. figure:: level-verify-gui.png 106 | :figwidth: 100% 107 | :align: center 108 | 109 | Verified conversation 110 | 111 | .. _sec-gui-authenticating: 112 | 113 | Authenticating 114 | '''''''''''''' 115 | The authentication of a conversation works by prompting both peers for 116 | a secret (which was exchanged through some other secure channel) and 117 | if the secrets provided match, they are sure they are chatting with 118 | the right person. Click ``Authenticate`` and provide the secret: 119 | 120 | .. figure:: auth-gui.png 121 | :figwidth: 100% 122 | :align: center 123 | 124 | Authentication window 125 | 126 | An authentication session is created when the secrets are exchanged 127 | and is valid until one of the peers disconnect. When it happens, the 128 | conversation is not authenticated anymore and a new session must be 129 | initialized when the peers reconnect. 130 | 131 | .. figure:: level-auth-gui.png 132 | :figwidth: 100% 133 | :align: center 134 | 135 | Authenticated conversation 136 | 137 | Assuming that one of the peers might be an attacker, this process is 138 | done with the `Socialist Millionaire Protocol`_ by comparing the 139 | secrets without actually disclosing them. 140 | 141 | Authentication Levels 142 | ''''''''''''''''''''' 143 | As noticed, unMessage conversations have three authentication levels: 144 | 145 | 1. Unverified Conversation 146 | 2. Verified Conversation 147 | 3. Authenticated Conversation 148 | 149 | When the conversation is established, its level is 150 | **Unverified Conversation** because unMessage does not know if you 151 | are sure that the peer's identity key is actually theirs. 152 | 153 | If you follow the :ref:`sec-gui-verifying` section, the level changes 154 | to **Verified Conversation** and it persists for as long the 155 | **conversation** exists. 156 | 157 | If you follow the :ref:`sec-gui-authenticating` section, the 158 | level changes to **Authenticated Conversation** and it persists for as 159 | long the **session** exists. Once the **session** is over, the level 160 | drops to the identity key's verification level: 161 | **Unverified**/**Verified**. 162 | 163 | .. important:: 164 | 165 | The **Authenticated** level is stronger than the **Verified** 166 | level because the former is a short term verification that lasts 167 | only until the peers disconnect, while the latter is long term 168 | that lasts until the conversation is deleted (manually, by the 169 | user). That means that with a short term verification you are able 170 | to authenticate the peer at that exact time, while a long term 171 | verification means that you authenticated the peer in the past, 172 | but is not aware of a compromise in the future. 173 | 174 | This feature aims to increase unMessage's security by identifying 175 | an attack that is not covered by the scope of the 176 | *Double Ratchet Algorithm*: compromised keys. 177 | 178 | Relaunching unMessage 179 | --------------------- 180 | unMessage remembers the last User Interface and Peer that you used. If 181 | you wish to use a shortcut, you may call:: 182 | 183 | unmessage 184 | 185 | .. _`socialist millionaire protocol`: https://en.wikipedia.org/wiki/Socialist_millionaire 186 | -------------------------------------------------------------------------------- /docs/source/gui/level-auth-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/level-auth-gui.png -------------------------------------------------------------------------------- /docs/source/gui/level-verify-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/level-verify-gui.png -------------------------------------------------------------------------------- /docs/source/gui/main-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/main-gui.png -------------------------------------------------------------------------------- /docs/source/gui/msg-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/msg-gui.png -------------------------------------------------------------------------------- /docs/source/gui/req-accept-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/req-accept-gui.png -------------------------------------------------------------------------------- /docs/source/gui/req-send-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/req-send-gui.png -------------------------------------------------------------------------------- /docs/source/gui/start-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/start-gui.png -------------------------------------------------------------------------------- /docs/source/gui/verify-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/docs/source/gui/verify-gui.png -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. unMessage documentation master file, created by 2 | sphinx-quickstart 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | ========= 8 | unMessage 9 | ========= 10 | 11 | What is it? 12 | =========== 13 | 14 | .. toctree:: 15 | :maxdepth: 0 16 | 17 | overview 18 | installation 19 | gui/gui 20 | cli/cli 21 | protocol 22 | 23 | Other 24 | ===== 25 | 26 | .. toctree:: 27 | :maxdepth: 0 28 | 29 | changelog 30 | feedback 31 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | unMessage's installation is done in three steps: 5 | 6 | 1. Install requirements 7 | 2. Use a virtual environment 8 | 3. Install unMessage 9 | 10 | Requirements 11 | ============ 12 | Install the following requirements via package manager:: 13 | 14 | $ # If using Debian/Ubuntu 15 | $ sudo apt-get install build-essential gcc libffi-dev libopus0 \ 16 | libsodium-dev libssl-dev portaudio19-dev python-dev python-tk 17 | 18 | $ # If using Fedora 19 | $ sudo dnf install gcc libffi-devel libsodium-devel \ 20 | openssl-devel opus portaudio-devel python-devel \ 21 | redhat-rpm-config tkinter 22 | 23 | If you have **tor** installed, make sure its version is at least 24 | ``0.2.7.1``:: 25 | 26 | $ tor --version 27 | 28 | If you must update it or do not have it installed, check the version 29 | provided by the package manager:: 30 | 31 | $ # If using Debian/Ubuntu 32 | $ apt-cache show tor 33 | 34 | $ # If using Fedora 35 | $ dnf info tor 36 | 37 | If the version to be provided is not at least ``0.2.7.1``, you will 38 | have to `set up Tor's package repository`_. Once you have a repository 39 | which can provide an updated **tor**, install it:: 40 | 41 | $ # If using Debian/Ubuntu 42 | $ sudo apt-get install tor 43 | 44 | $ # If using Fedora 45 | $ sudo dnf install tor 46 | 47 | Using a Virtual Environment 48 | =========================== 49 | Install `virtualenv`_, `pip`_ and `setuptools`_:: 50 | 51 | $ # If using Debian/Ubuntu 52 | $ sudo apt-get install python-virtualenv 53 | 54 | $ # If using Fedora 55 | $ sudo dnf install python-virtualenv 56 | 57 | Use a *virtual environment*:: 58 | 59 | $ virtualenv ~/unmessage-env # create 60 | $ . ~/unmessage-env/bin/activate # activate 61 | (unmessage-env)$ # prompt shows which environment is active 62 | 63 | Update *setuptools*, *pip* and *virtualenv*:: 64 | 65 | (unmessage-env)$ pip install --upgrade setuptools 66 | (unmessage-env)$ pip install --upgrade pip 67 | (unmessage-env)$ pip install --upgrade virtualenv 68 | 69 | Make sure that the update installs at least *pip* ``8`` and 70 | *setuptools* ``19.4``. 71 | 72 | Installing 73 | ========== 74 | Finally, install unMessage:: 75 | 76 | (unmessage-env)$ pip install unmessage 77 | 78 | Launch unMessage with any of the commands:: 79 | 80 | (unmessage-env)$ unmessage-gui # graphical user interface (GUI) 81 | (unmessage-env)$ unmessage-cli # command-line interface (CLI) 82 | (unmessage-env)$ unmessage # last interface used 83 | 84 | Make sure to activate the *virtual environment* whenever you wish to 85 | use unMessage:: 86 | 87 | $ . ~/unmessage-env/bin/activate 88 | 89 | As well as deactivate it when you are done:: 90 | 91 | 92 | (unmessage-env)$ deactivate 93 | 94 | Updating 95 | ======== 96 | *pip* can also be used to update unMessage:: 97 | 98 | (unmessage-env)$ pip install --upgrade unmessage 99 | 100 | ===== 101 | Usage 102 | ===== 103 | unMessage offers usage instructions for both interfaces: 104 | :ref:`sec-gui` and :ref:`sec-cli`. 105 | 106 | =========== 107 | Persistence 108 | =========== 109 | All files used by unMessage are saved in ``~/.config/unMessage/`` 110 | 111 | .. _`cryptully`: https://github.com/shanet/Cryptully 112 | .. _`curses`: https://docs.python.org/2/library/curses.html 113 | .. _`pip`: https://pypi.python.org/pypi/pip 114 | .. _`pyaxo`: https://github.com/rxcomm/pyaxo 115 | .. _`set up tor's package repository`: https://www.torproject.org/docs/debian.html.en#ubuntu 116 | .. _`setuptools`: https://pypi.python.org/pypi/setuptools 117 | .. _`tkinter`: https://docs.python.org/2/library/tkinter.html 118 | .. _`tor onion services`: https://www.torproject.org/docs/hidden-services.html 119 | .. _`twisted`: https://twistedmatrix.com 120 | .. _`txtorcon`: https://github.com/meejah/txtorcon 121 | .. _`virtualenv`: https://pypi.python.org/pypi/virtualenv 122 | -------------------------------------------------------------------------------- /docs/source/overview.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Overview 3 | ======== 4 | unMessage is a peer-to-peer instant messaging application designed 5 | to enhance privacy and anonymity. 6 | 7 | .. warning:: 8 | 9 | unMessage is **alpha** software. While every effort has been made 10 | to make sure unMessage operates in a secure and bug-free fashion, 11 | the code has **not** been audited. Please do not use unMessage for 12 | any activity that your life depends upon. 13 | 14 | Features 15 | -------- 16 | - Transport makes use of `Twisted`_, `Tor Onion Services`_ and 17 | `txtorcon`_ 18 | 19 | - Encryption is performed using the `Double Ratchet Algorithm`_ 20 | implemented in `pyaxo`_ (using `PyNaCl`_) 21 | 22 | - Authentication makes use of the `Socialist Millionaire Protocol`_ 23 | implemented in `Cryptully`_ 24 | 25 | - Transport metadata is minimized by *Tor* and application metadata by 26 | the :ref:`sec-protocol` 27 | 28 | - User interfaces are created with `Tkinter`_ (graphical) and 29 | `curses`_ (command-line) 30 | 31 | .. _`cryptully`: https://github.com/shanet/Cryptully 32 | .. _`curses`: https://docs.python.org/2/library/curses.html 33 | .. _`double ratchet algorithm`: https://whispersystems.org/docs/specifications/doubleratchet 34 | .. _`pyaxo`: https://github.com/rxcomm/pyaxo 35 | .. _`pynacl`: https://github.com/pyca/pynacl 36 | .. _`socialist millionaire protocol`: https://en.wikipedia.org/wiki/Socialist_millionaire 37 | .. _`tkinter`: https://docs.python.org/2/library/tkinter.html 38 | .. _`tor onion services`: https://www.torproject.org/docs/hidden-services.html 39 | .. _`twisted`: https://twistedmatrix.com 40 | .. _`txtorcon`: https://github.com/meejah/txtorcon 41 | -------------------------------------------------------------------------------- /docs/source/protocol.rst: -------------------------------------------------------------------------------- 1 | .. _sec-protocol: 2 | 3 | ================== 4 | unMessage Protocol 5 | ================== 6 | This section describes the logic for sending/accepting requests and 7 | exchanging messages in :ref:`sec-protocol-logic`, as well as the 8 | packets used in each of those stages in :ref:`sec-protocol-packets`. 9 | 10 | .. _sec-protocol-logic: 11 | 12 | Establishing Conversations 13 | ========================== 14 | The unMessage protocol is based on the `Double Ratchet Algorithm`_ to 15 | establish conversations and exchange messages privately and 16 | anonymously. 17 | 18 | .. note:: 19 | 20 | unMessage uses `Tor Onion Services`_ to anonymously connect peers 21 | as we believe that it is the best transport for this kind of 22 | application, but other approaches such as posting the packets to a 23 | public mailing list should also work (as long as the packets are 24 | anonymously posted). 25 | 26 | In the *Double Ratchet Algorithm*, a **secret key** must be agreed on 27 | to derive all the other keys involved in the conversation. The 28 | **secret key** used by unMessage is generated with the 29 | `Triple Diffie-Hellman Key Agreement`_, using one party's 30 | **public identity and handshake keys**, and another's 31 | **private identity and handshake keys**. 32 | 33 | Each party must have its mode assigned to as either **Alice** or 34 | **Bob**. The one who starts the initialization is **Bob** and can 35 | send messages right after the **secret key** is generated. As part of 36 | the initialization, **Bob** must send his **public ratchet key** to 37 | **Alice** so that she is able to start the 38 | `Diffie-Hellman ratcheting`_ and also send messages immediately. 39 | 40 | unMessage conversations have the following stages: 41 | 42 | 1. Request sent 43 | 2. Request accepted 44 | 3. Conversation established 45 | 46 | In order to send requests, both parties must launch unMessage to 47 | generate their *Onion Service* and *Double Ratchet* keypairs. 48 | unMessage is a **serverless** application, so a peer who wishes to 49 | receive requests must send/publish their *Onion Service* address and 50 | *Double Ratchet* public identity key through some other communication 51 | channel. 52 | 53 | unMessage assigns **Bob** to the one who sends a request and **Alice** 54 | to the one who receives it. 55 | 56 | .. important:: 57 | 58 | In the following sections, the **shared request key** and 59 | **conversation ID** are described as the direct input of hash and 60 | encryption functions for simplicity. In fact, these keys are input 61 | of a *Key Derivation Function (KDF)* along with its respective 62 | *salt*, and the output keys of the *KDF* that are actually used 63 | by such functions. 64 | 65 | Stage 1: Request sent 66 | ---------------------- 67 | A **request keypair** is generated by **Bob's** unMessage to derive a 68 | *Diffie-Hellman* **shared request key** using the 69 | **private request key** and **Alice's public identity key**. 70 | The **shared request key**, is used to encrypt the following 71 | information needed by **Alice** to initialize a conversation with 72 | **Bob**: 73 | 74 | - Bob's identity address 75 | - Bob's identity public key 76 | - Bob's handshake public key 77 | - Bob's ratchet public key 78 | 79 | This set composes the **handshake packet**, which after encrypted is 80 | used to compose the **request packet**: 81 | 82 | - IV 83 | - hash(IV + Alice's public identity key + shared request key) 84 | - keyed_hash(shared request key, encrypted handshake packet) 85 | - public request key 86 | - encrypted handshake packet 87 | 88 | The packet is then sent to **Alice's** *Onion Address* and **Stage 1** 89 | is completed. 90 | 91 | .. important:: 92 | 93 | The **handshake packet** should be signed by the *Onion Service* 94 | and *Double Ratchet* keys so that a peer cannot advertise keys 95 | they do not own. This will be implemented in a future version of 96 | unMessage. 97 | 98 | Stage 2: Request accepted 99 | -------------------------- 100 | After receiving the **request packet**, **Alice's** unMessage derives 101 | the **shared request key** using **Alice's private identity key** and 102 | the **public request key**. The **shared request key** is hashed with 103 | the **IV** and the **handshake packet** to make sure that is indeed an 104 | unMessage **request packet** and the **handshake packet** can be 105 | decrypted. **Alice** is notified that the request was received from 106 | **Bob** and accepts it to initialize the *Double Ratchet* 107 | conversation. 108 | 109 | **Bob's public identity and handshake keys** sent in the 110 | **handshake packet** are used to generate the *Double Ratchet* 111 | **secret key** with 112 | **Alice's private identity and handshake keys** (the former was 113 | generated when unMessage was launched by the first time and the latter 114 | when the request was accepted, to be used for this specific 115 | conversation). The *Double Ratchet* conversation is finally 116 | initialized using the **secret key** and **Bob's public ratchet key** 117 | (also sent in the **handshake packet**). At this point, **Stage 2** 118 | is completed and **Alice** can start sending encrypted messages. 119 | However, as **Bob** does not have **Alice's public handshake key**, it 120 | is encrypted (using the **shared request key**) and sent along with 121 | the unMessage **reply packet**: 122 | 123 | - IV 124 | - hash(IV + Bob's public identity key + shared request key) 125 | - keyed_hash(shared request key, encrypted handshake key + encrypted payload) 126 | - Alice's encrypted public handshake key 127 | - encrypted payload 128 | 129 | Stage 3: Conversation established 130 | --------------------------------- 131 | When messages from **Alice** are received, **Bob's** unMessage hashes 132 | the **shared request key** with the **IV** and 133 | **Alice's encrypted public handshake key** concatenated with the 134 | **encrypted payload** to make sure that is indeed an unMessage 135 | **packet** from **Alice**, and her **public handshake key** can be 136 | decrypted. **Bob** now can also generate the **secret key** with his 137 | **private identity and handshake keys**, and 138 | **Alice's public identity and handshake keys**. With his part of 139 | the conversation initialized, he can start sending unMessage 140 | **regular packets**: 141 | 142 | - IV 143 | - hash(IV + Alice's public identity key + conversation ID) 144 | - keyed_hash(conversation ID, encrypted payload) 145 | - encrypted payload 146 | 147 | **Stage 3** is completed when **Alice** receives a **regular packet** 148 | from **Bob**, which means that he was able to initialize the 149 | conversation with her **public handshake key** and there is no need 150 | to send **reply packets** anymore, so her unMessage also starts 151 | sending **regular packets**. 152 | 153 | Identifying conversations 154 | ------------------------- 155 | All of the identifying information of an unMessage packet is encrypted 156 | so that an attacker who intercepts it cannot tell who are the receiver 157 | and sender. 158 | 159 | When a packet is received, unMessage assumes it is a 160 | **regular packet** and attempts to use all of the peer's 161 | **conversation IDs** to derive the **IV hash**. If the hash matches 162 | the packet's **IV hash**, unMessage identifies the sender and is able 163 | to decrypt the **payload** (after verifying its integrity). If the 164 | **IV hash** does not match, unMessage assumes the packet is a 165 | **request packet** and derives a **shared request key** using the 166 | **public request key** from the packet and the peer's 167 | **public identity key**. unMessage attempts to use the 168 | **shared request key** and the **IV** to derive a hash that matches 169 | the packet's **IV hash**. If it matches, unMessage checks the 170 | integrity of the rest of the packet and processes the request as 171 | described in **Stage 2**. 172 | 173 | When unMessage fails to identify or check the integrity of packets, 174 | they are ignored. 175 | 176 | .. note:: 177 | 178 | The **IV hash** also uses the receiver's public identity key as 179 | part of the hash so that, for example, Alice can tell the 180 | difference between messages she sent to Bob and messages she 181 | received from Bob. 182 | 183 | The **IV hash** is another implementation of an `hSub`_. 184 | 185 | .. _sec-protocol-packets: 186 | 187 | Packet Formats 188 | ============== 189 | unMessage's conversations have three stages, each using a different 190 | packet format: 191 | 192 | - **Request:** contains Bob's name, address and keys (identity, 193 | handshake and ratchet) 194 | 195 | - **Reply:** contains Alice's key (handshake) and optionally an 196 | encrypted element 197 | 198 | - **Regular:** contains an encrypted element 199 | 200 | .. note:: 201 | 202 | **Elements** are the plaintext of the information exchanged in 203 | unMessage's conversations, which are wrapped by Double Ratchet's 204 | encryption and added to reply/regular packets for transmission. 205 | (e.g., *presence notifications*, *text messages*, 206 | *authentication buffers*) 207 | 208 | The following sections summarize what each packet is used for, their 209 | exact contents and their size in *bytes*. 210 | 211 | *(In the following diagrams, data surrounded by* ``===`` 212 | *is encrypted)* 213 | 214 | Request Packet 215 | -------------- 216 | To notify **Alice** that **Bob** wishes to establish a conversation 217 | with her, he must send all the information she needs to complete this 218 | process. The information is sent in a **request packet**: 219 | 220 | .. code-block:: text 221 | 222 | +------------------------------------------------+ 223 | | Request packet (240 + address) | 224 | +------------------------------------------------+ 225 | | IV (8) | 226 | | IV Hash (32) | 227 | | Keyed hash (32) | 228 | | Public request key (32) | 229 | | | 230 | | +--------------------------------------------+ | 231 | | | Encrypted handshake packet (136 + address) | | 232 | | +--------------------------------------------+ | 233 | | | Nonce (24) | | 234 | | | MAC (16) | | 235 | | | +========================================+ | | 236 | | | | Identity address | | | 237 | | | | Public identity key (32) | | | 238 | | | | Public handshake key (32) | | | 239 | | | | Public ratchet key (32) | | | 240 | | | +========================================+ | | 241 | | +--------------------------------------------+ | 242 | +------------------------------------------------+ 243 | 244 | The **request key** is used to derive a **shared request key** with 245 | **Alice's** identity key in order to encrypt **Bob**'s information so 246 | that only the ones in possession of the private **request** or 247 | **identity** keys are able to read who sent the request. 248 | 249 | Reply Packet 250 | ------------ 251 | Once **Alice** accepts the request, she is able to send encrypted 252 | elements to **Bob**, who sent all information required by her to 253 | initialize a conversation. However, as **Bob** needs her 254 | **handshake key**, she adds it before the payload of the message, in 255 | case an element should also be included. This information is sent in a 256 | **reply packet**: 257 | 258 | 259 | .. code-block:: text 260 | 261 | +------------------------------------------------+ 262 | | Reply packet (192 + 72 + payload) | 263 | +------------------------------------------------+ 264 | | IV (8) | 265 | | IV Hash (32) | 266 | | Keyed hash (32) | 267 | | | 268 | | +--------------------------------------------+ | 269 | | | Encrypted public handshake key (72) | | 270 | | +--------------------------------------------+ | 271 | | | Nonce (24) | | 272 | | | MAC (16) | | 273 | | | +========================================+ | | 274 | | | | Public handshake key (32) | | | 275 | | | +========================================+ | | 276 | | +--------------------------------------------+ | 277 | | | 278 | | +--------------------------------------------+ | 279 | | | Encrypted payload (120 + payload) | | 280 | | +--------------------------------------------+ | 281 | | | +----------------------------------------+ | | 282 | | | | Double Ratchet header (80) | | | 283 | | | +----------------------------------------+ | | 284 | | | | Nonce (24) | | | 285 | | | | MAC (16) | | | 286 | | | | +====================================+ | | | 287 | | | | | Ns (3) | | | | 288 | | | | | PNs (3) | | | | 289 | | | | | DHRs (32) | | | | 290 | | | | +====================================+ | | | 291 | | | | Padding (2) | | | 292 | | | +----------------------------------------+ | | 293 | | | | | 294 | | | +----------------------------------------+ | | 295 | | | | Double Ratchet payload (40 + payload) | | | 296 | | | +----------------------------------------+ | | 297 | | | | Nonce (24) | | | 298 | | | | MAC (16) | | | 299 | | | | +====================================+ | | | 300 | | | | | Payload | | | | 301 | | | | +====================================+ | | | 302 | | | +----------------------------------------+ | | 303 | | +--------------------------------------------+ | 304 | +------------------------------------------------+ 305 | 306 | In order to send multiple messages to **Bob** (which might be 307 | delivered out of order), **Alice** must continue to send her 308 | **handshake key** until **Bob** replies (signaling that he was able to 309 | establish a conversation as well). To prevent **reply packets** from 310 | being linked by leaking the **handshake key**, it is encrypted using 311 | the **shared request key** used in the encryption of the 312 | **request packet** sent by **Bob**. 313 | 314 | Regular Packet 315 | -------------- 316 | Once both peers have initialized their sides of the conversation, 317 | there is no need for **Alice** to send the **handshake key** anymore. 318 | The only content subsequent exchanges transmit are their payloads. 319 | This information is sent in a **regular packet**: 320 | 321 | .. code-block:: text 322 | 323 | +------------------------------------------------+ 324 | | Regular packet (192 + payload) | 325 | +------------------------------------------------+ 326 | | IV (8) | 327 | | IV Hash (32) | 328 | | Keyed hash (32) | 329 | | | 330 | | +--------------------------------------------+ | 331 | | | Encrypted payload (120 + payload) | | 332 | | +--------------------------------------------+ | 333 | | | +----------------------------------------+ | | 334 | | | | Double Ratchet header (80) | | | 335 | | | +----------------------------------------+ | | 336 | | | | Nonce (24) | | | 337 | | | | MAC (16) | | | 338 | | | | +====================================+ | | | 339 | | | | | Ns (3) | | | | 340 | | | | | PNs (3) | | | | 341 | | | | | DHRs (32) | | | | 342 | | | | +====================================+ | | | 343 | | | | Padding (2) | | | 344 | | | +----------------------------------------+ | | 345 | | | | | 346 | | | +----------------------------------------+ | | 347 | | | | Double Ratchet payload (40 + payload) | | | 348 | | | +----------------------------------------+ | | 349 | | | | Nonce (24) | | | 350 | | | | MAC (16) | | | 351 | | | | +====================================+ | | | 352 | | | | | Payload | | | | 353 | | | | +====================================+ | | | 354 | | | +----------------------------------------+ | | 355 | | +--------------------------------------------+ | 356 | +------------------------------------------------+ 357 | 358 | .. important:: 359 | 360 | Despite the fact that each packet's contents look like random 361 | information, in the current version of unMessage all of them have 362 | a different size. In the future, all packets should be padded to a 363 | fixed size in order to achieve indistinguishability. 364 | 365 | Threat Model 366 | ============ 367 | unMessage is characterized by the packets it creates and processes, 368 | and the transport used to transmit such packets between its peers. Tor 369 | Onion Services is the current supported solution used to connect 370 | peers, but as unMessage employs an application protocol that manages 371 | its own packets, it would be possible to allow the use of other 372 | transports as long as such packets are transmitted from one peer to 373 | another, anonymously. 374 | 375 | It is expected that the transport connecting the peers conceals their 376 | real identity, location and path of transmissions from each other as 377 | well as from an external adversary observing the network that is not 378 | as powerful as a Global Passive Adversary. From this perspective, 379 | unMessage is susceptible to the same security vulnerabilities as the 380 | transport in use. 381 | 382 | Although unMessage expects that information to be anonymously 383 | *exchanged* between the peers, it does not require anything beyond 384 | that because by default its packet format provides: 385 | 386 | - Integrity 387 | - Authenticity 388 | - Confidentiality 389 | - Anonymity 390 | 391 | As conversations are established between peers with Double Ratchet 392 | sessions, they also benefit from the properties of: 393 | 394 | - Forward secrecy 395 | - Future secrecy 396 | - Deniability 397 | 398 | .. _sec-protocol-adversary: 399 | 400 | Adversary Capabilities 401 | ---------------------- 402 | From the application's perspective, taking into account the local 403 | server availability, packet creation and packet processing, we assume 404 | the following capabilities from an adversary: 405 | 406 | 1. An adversary is unable to break the cryptographic primitives used by 407 | unMessage. 408 | 409 | 2. An adversary is able to observe, intercept, replay and modify all 410 | packets exchanged by the peers. 411 | 412 | 3. An adversary is able to send requests and malformed packets to a 413 | peer whose unMessage address and public identity key has been 414 | acknowledged by them. 415 | 416 | 4. An adversary is unable to perform an attack by making multiple 417 | connections or sending multiple requests to a peer whose unMessage 418 | address has been acknowledged by them, making that peer 419 | unavailable to others. 420 | 421 | 5. An adversary is unable to send malformed/malicious elements 422 | to a peer who accepted their request and therefore has established 423 | a conversation with. 424 | 425 | 6. An adversary is unable to compromise a peer's private identity key 426 | to impersonate them in current and future conversations. 427 | 428 | 7. An adversary is unable to compromise a peer's private identity key 429 | to decrypt any of the requests they received/accepted. 430 | 431 | .. _sec-protocol-attacks: 432 | 433 | Possible Attacks 434 | ---------------- 435 | Some limitations to the adversary's capabilities had to be imposed 436 | due to the current implementation of unMessage, which does not yet 437 | prevent some of the attacks mentioned in 438 | :ref:`sec-protocol-adversary`: 439 | 440 | - unMessage maps an Onion Service to a local server that accepts 441 | connections and `receives netstrings`_ to be parsed as unMessage 442 | packets. This behavior allows an adversary who has knowledge of a 443 | peer's Onion Service address to perform the attack mentioned in 444 | item `4`, by either making the Onion Service inaccessible in the 445 | network or overloading the unMessage instance. 446 | 447 | - Although unMessage validates the format of the packets it expects to 448 | receive and an adversary cannot make any modifications due to the 449 | integrity checks, once a packet is decrypted after being validated 450 | by passing such checks, there is not yet a mechanism validating the 451 | conversation elements (i.e., the plaintext of reply/regular packets) 452 | and the attack mentioned in item `5` is possible to be performed. 453 | 454 | - The attack mentioned in item `6` can be mitigated as unMessage 455 | provides an authentication feature. As long as the users have 456 | securely agreed on a secret that is not known by the adversary, the 457 | party who the impersonator is communicating with can use it to 458 | initiate the authentication process and detect the attack. Even 459 | though any user can initiate the authentication at any time after a 460 | conversation is established, it is up to them to properly handle the 461 | secret and regularly authenticate themselves - or at least do so 462 | under any suspicion. Even if users take such actions, unMessage is 463 | only able to detect instead of prevent it. 464 | 465 | - All the peer information required to send/receive conversation 466 | requests is encrypted with a shared secret derived from a 467 | Diffie-Hellman key exchange using Bob's request key and Alice's 468 | identity key. The request key is ephemeral and is disposed once the 469 | conversation is established, but the identity key is not. For that 470 | reason, the attack mentioned in item `7` is possible to be performed 471 | but not prevented by unMessage. 472 | 473 | .. _`diffie-hellman ratcheting`: https://whispersystems.org/docs/specifications/doubleratchet/#diffie-hellman-ratchet 474 | .. _`double ratchet algorithm`: https://whispersystems.org/docs/specifications/doubleratchet 475 | .. _`hsub`: http://is-not-my.name/hsub.html 476 | .. _`receives netstrings`: https://twistedmatrix.com/documents/16.6.0/api/twisted.protocols.basic.NetstringReceiver.html 477 | .. _`tor onion services`: https://www.torproject.org/docs/hidden-services.html 478 | .. _`triple diffie-hellman key agreement`: https://whispersystems.org/blog/simplifying-otr-deniability 479 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -ra -v --cov=./ 3 | twisted = 1 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | codecov 2 | hypothesis 3 | pytest 4 | pytest-cov 5 | pytest-mock 6 | pytest-twisted 7 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx 2 | sphinx_rtd_theme 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==17.2.0 2 | opuslib==1.1.0 3 | PyAudio==0.2.11 4 | pyaxo==0.8.2 5 | PyNaCl==1.1.2 6 | pyperclip==1.5.27 7 | Twisted[tls]==17.1.0 8 | txtorcon==0.19.3 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [versioneer] 2 | VCS = git 3 | versionfile_source = unmessage/_version.py 4 | versionfile_build = 5 | tag_prefix = 6 | parentdir_prefix = unmessage 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | import versioneer 4 | 5 | # Hmmmph. 6 | # So we get all the meta-information in one place (yay!) but we call 7 | # exec to get it (boo!). Note that we can't import fom _metadata here 8 | # because that won't work when setup is being run by pip (outside of 9 | # Git checkout etc) 10 | with open('unmessage/_metadata.py') as f: 11 | exec( 12 | compile(f.read(), '_metadata.py', 'exec'), 13 | globals(), 14 | locals(), 15 | ) 16 | 17 | setup( 18 | name='unmessage', 19 | version=versioneer.get_version(), 20 | cmdclass=versioneer.get_cmdclass(), 21 | description='Privacy enhanced instant messenger', 22 | long_description=open('README.rst', 'r').read(), 23 | url=__url__, 24 | author=__author__, 25 | author_email=__contact__, 26 | license=__license__, 27 | classifiers=[ 28 | 'Development Status :: 3 - Alpha', 29 | 'Intended Audience :: Developers', 30 | 'Intended Audience :: End Users/Desktop', 31 | 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', 32 | 'Operating System :: POSIX :: Linux', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Topic :: Communications :: Chat', 35 | ], 36 | keywords='messenger privacy anonimity pyaxo axolotl double ratchet tor', 37 | packages=find_packages(), 38 | install_requires=open('requirements.txt').readlines(), 39 | # "pip install -e .[dev]" will install development requirements 40 | extras_require=dict( 41 | dev=open('requirements-dev.txt').readlines(), 42 | ), 43 | entry_points={ 44 | 'console_scripts': [ 45 | 'unmessage = unmessage.__main__:main', 46 | 'unmessage-cli = unmessage.__main__:launch_cli', 47 | ], 48 | 'gui_scripts': [ 49 | 'unmessage-gui = unmessage.__main__:launch_gui', 50 | ], 51 | }, 52 | ) 53 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AnemoneLabs/unmessage/d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from unmessage.log import begin_logging, Logger, LogLevel 4 | 5 | from .utils import attach, create_peer 6 | from .utils import slow, slow_help, slow_option 7 | 8 | 9 | def skipif_option(option): 10 | return pytest.mark.skipif(not pytest.config.getoption(option), 11 | reason='need {} option to run'.format(option)) 12 | 13 | 14 | def pytest_collection_modifyitems(items): 15 | for item in items: 16 | if slow.name in item.keywords: 17 | item.add_marker(skipif_option(slow_option)) 18 | 19 | 20 | def pytest_addoption(parser): 21 | parser.addoption(slow_option, action='store_true', help=slow_help) 22 | 23 | 24 | @pytest.fixture 25 | def callback_side_effect(mocker): 26 | def side_effect(d): 27 | return mocker.Mock(side_effect=lambda *args: d.callback(*args)) 28 | return side_effect 29 | 30 | 31 | @pytest.fixture(scope='session') 32 | def reactor(): 33 | from twisted.internet import reactor as _reactor 34 | return _reactor 35 | 36 | 37 | @pytest.fixture 38 | def peer_a(reactor): 39 | return create_peer('pytest-a', reactor) 40 | 41 | 42 | @pytest.fixture 43 | def peer_b(reactor): 44 | return create_peer('pytest-b', reactor) 45 | 46 | 47 | @pytest.fixture 48 | def peers(peer_a, peer_b, mocker): 49 | attach(peer_a, peer_b, mocker) 50 | return (peer_b._send_request(peer_a.identity, peer_a.identity_keys.pub) 51 | .addCallback(lambda *args: peer_a.accept_request(peer_b.identity)) 52 | .addCallback(lambda *args: (peer_a, peer_b))) 53 | 54 | 55 | @pytest.fixture 56 | def peers_conversations(peers): 57 | return peers.addCallback( 58 | lambda (peer_a, peer_b): (peer_a, 59 | peer_b, 60 | peer_a._conversations[peer_b.name], 61 | peer_b._conversations[peer_a.name])) 62 | 63 | 64 | @pytest.fixture 65 | def conversations(peers): 66 | return peers.addCallback( 67 | lambda (peer_a, peer_b): (peer_a._conversations[peer_b.name], 68 | peer_b._conversations[peer_a.name])) 69 | 70 | 71 | @pytest.fixture 72 | def content(): 73 | return 'foo' 74 | 75 | 76 | @pytest.fixture(scope='session') 77 | def log(): 78 | begin_logging('/tmp/unmessage.log', LogLevel.debug) 79 | return Logger('pytest') 80 | -------------------------------------------------------------------------------- /tests/test_conversation.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from twisted.internet.defer import Deferred 3 | 4 | from unmessage import elements 5 | from unmessage.contact import Contact 6 | from unmessage.peer import b2a 7 | 8 | from .utils import attach, slow 9 | 10 | 11 | def test_conversation_request(peer_a, peer_b): 12 | contact_a = Contact(peer_a.identity, peer_a.identity_keys.pub) 13 | out_request = peer_b._create_request(contact_a) 14 | packet = out_request.packet 15 | 16 | in_request = peer_a._process_request(str(packet)) 17 | contact_b = in_request.conversation.contact 18 | 19 | assert contact_b.identity == peer_b.identity 20 | assert contact_b.key == peer_b.identity_keys.pub 21 | 22 | 23 | def check_established_conversation(peer_x, peer_y, conv_x, conv_y): 24 | assert conv_x.contact.identity == peer_y.identity 25 | assert conv_y.contact.identity == peer_x.identity 26 | assert conv_x.contact.key == peer_y.identity_keys.pub 27 | assert conv_y.contact.key == peer_x.identity_keys.pub 28 | assert conv_x.axolotl.id_ == conv_y.axolotl.id_ 29 | assert conv_x.keys.key == conv_y.keys.key 30 | 31 | 32 | @pytest.inlineCallbacks 33 | def test_establish_conversation(peer_a, peer_b, mocker): 34 | attach(peer_a, peer_b, mocker) 35 | 36 | yield peer_b.send_request(peer_a.identity, b2a(peer_a.identity_keys.pub)) 37 | yield peer_a.accept_request(peer_b.identity) 38 | 39 | conv_a = peer_a._conversations[peer_b.name] 40 | conv_b = peer_b._conversations[peer_a.name] 41 | 42 | check_established_conversation(peer_a, peer_b, conv_a, conv_b) 43 | 44 | 45 | @pytest.inlineCallbacks 46 | def test_prepare_accept_request(request_element, peer_a, peer_b, mocker): 47 | attach(peer_a, peer_b, mocker) 48 | 49 | yield peer_b.send_request(peer_a.identity, b2a(peer_a.identity_keys.pub)) 50 | request = peer_a._inbound_requests[peer_b.identity] 51 | element, _ = peer_a._prepare_accept_request(request) 52 | 53 | assert element == request_element 54 | 55 | 56 | @pytest.inlineCallbacks 57 | def test_established_peers(peers_conversations): 58 | peer_a, peer_b, conv_a, conv_b = yield peers_conversations 59 | 60 | check_established_conversation(peer_a, peer_b, conv_a, conv_b) 61 | 62 | 63 | @pytest.inlineCallbacks 64 | def test_send_presence(peers_conversations, callback_side_effect): 65 | peer_a, peer_b, conv_a, conv_b = yield peers_conversations 66 | 67 | d_offline = Deferred() 68 | conv_b.ui.notify_offline = callback_side_effect(d_offline) 69 | d_online_a = Deferred() 70 | conv_a.ui.notify_online = callback_side_effect(d_online_a) 71 | d_online_b = Deferred() 72 | conv_b.ui.notify_online = callback_side_effect(d_online_b) 73 | 74 | peer_a.set_presence(peer_b.name, enable=True) 75 | 76 | yield peer_a._send_offline_presence() 77 | yield d_offline 78 | conv_a.close() 79 | assert not conv_a.is_active 80 | assert not conv_b.is_active 81 | 82 | peer_a._send_online_presence() 83 | yield d_online_a 84 | yield d_online_b 85 | assert conv_a.is_active 86 | assert conv_b.is_active 87 | 88 | 89 | PRESENCE_STATUSES = {elements.PresenceElement.status_offline: True, 90 | elements.PresenceElement.status_online: False} 91 | 92 | 93 | @pytest.inlineCallbacks 94 | @pytest.mark.parametrize('status', 95 | PRESENCE_STATUSES.values(), 96 | ids=PRESENCE_STATUSES.keys()) 97 | def test_prepare_presence(status, peers_conversations): 98 | peer_a, peer_b, conv_a, _ = yield peers_conversations 99 | 100 | peer_a.set_presence(peer_b.name, enable=True) 101 | 102 | presence_elements = peer_a._prepare_presence(status) 103 | peer_b_presence_element, peer_b_presence_conv = presence_elements[0] 104 | contents = {v: k for k, v in PRESENCE_STATUSES.items()} 105 | 106 | assert peer_b_presence_conv == conv_a 107 | assert len(presence_elements) == 1 108 | assert isinstance(peer_b_presence_element, elements.PresenceElement) 109 | assert str(peer_b_presence_element) == contents[status] 110 | 111 | 112 | @pytest.inlineCallbacks 113 | def test_send_message(content, conversations, callback_side_effect): 114 | conv_a, conv_b = yield conversations 115 | 116 | d = Deferred() 117 | conv_b.ui.notify_message = callback_side_effect(d) 118 | 119 | yield conv_a.send_message(content) 120 | received_message = yield d 121 | assert str(received_message) == content 122 | 123 | 124 | @pytest.inlineCallbacks 125 | def test_prepare_message(message_element, content, conversations): 126 | conv_a, _ = yield conversations 127 | 128 | element = conv_a._prepare_message(content) 129 | assert element == message_element 130 | 131 | 132 | SECRETS = {'same': ('secret', 'secret'), 133 | 'distinct': ('secret', 'wrong secret')} 134 | 135 | 136 | @slow 137 | @pytest.inlineCallbacks 138 | @pytest.mark.parametrize('secrets', SECRETS.values(), ids=SECRETS.keys()) 139 | def test_authenticate(secrets, conversations, callback_side_effect): 140 | conv_a, conv_b = yield conversations 141 | 142 | d_receive_b = Deferred() 143 | conv_b.ui.notify_in_authentication = callback_side_effect(d_receive_b) 144 | d_finish_a = Deferred() 145 | conv_a.ui.notify_finished_authentication = callback_side_effect(d_finish_a) 146 | d_finish_b = Deferred() 147 | conv_b.ui.notify_finished_authentication = callback_side_effect(d_finish_b) 148 | 149 | secret_a, secret_b = secrets 150 | yield conv_a.authenticate(secret_a) 151 | yield d_receive_b 152 | yield conv_b.authenticate(secret_b) 153 | yield d_finish_b 154 | yield d_finish_a 155 | 156 | authenticated = secret_a == secret_b 157 | 158 | assert conv_a.is_authenticated is authenticated 159 | assert conv_b.is_authenticated is authenticated 160 | 161 | 162 | @pytest.inlineCallbacks 163 | def test_prepare_authentication(conversations): 164 | conv_a, _ = yield conversations 165 | 166 | secret = 'secret' 167 | conv_a.init_auth() 168 | element, _ = conv_a._prepare_authentication(secret) 169 | assert isinstance(element, elements.AuthenticationElement) 170 | 171 | 172 | @pytest.fixture 173 | def request_element(): 174 | return elements.RequestElement(elements.RequestElement.request_accepted) 175 | 176 | 177 | @pytest.fixture 178 | def message_element(content): 179 | return elements.MessageElement(content) 180 | -------------------------------------------------------------------------------- /tests/test_elements.py: -------------------------------------------------------------------------------- 1 | import json 2 | from math import ceil 3 | 4 | import pytest 5 | from hypothesis import example, given 6 | from hypothesis.strategies import integers 7 | from pyaxo import hash_, a2b, b2a 8 | 9 | from unmessage import errors 10 | from unmessage.elements import Element, FileRequestElement, PartialElement 11 | from unmessage.elements import ID_LENGTH, get_random_id 12 | from unmessage.packets import ElementPacket 13 | from unmessage.peer import MAX_ELEMENT_LEN 14 | 15 | 16 | ELEMENT_CLASSES = {cls.__name__: cls for cls in Element.__subclasses__()} 17 | 18 | 19 | @pytest.mark.parametrize('cls', 20 | ELEMENT_CLASSES.values(), 21 | ids=ELEMENT_CLASSES.keys()) 22 | def test_element_factory(cls, 23 | serialized_payload, file_request_serialized_payload): 24 | if cls is FileRequestElement: 25 | payload = file_request_serialized_payload 26 | else: 27 | payload = serialized_payload 28 | e = Element.build(cls.type_, payload) 29 | assert isinstance(e, cls) 30 | assert e.serialize() == payload 31 | 32 | 33 | def test_element_factory_unknown_type(serialized_payload): 34 | with pytest.raises(errors.UnknownElementError): 35 | Element.build('unknown', serialized_payload) 36 | 37 | 38 | @given(integers(min_value=4, max_value=MAX_ELEMENT_LEN)) 39 | @example(0) 40 | @example(4) 41 | @example(10) 42 | @example(MAX_ELEMENT_LEN) 43 | def test_partial_from_element(element, id_, max_len): 44 | packets = to_packets(max_len, element, id_, element.serialize()) 45 | partial = PartialElement.from_element(element, id_, max_len) 46 | assert partial.id_ == id_ 47 | assert partial.part_total == len(packets) 48 | assert len(partial) == partial.part_total 49 | assert partial.to_element() == element 50 | 51 | 52 | @given(integers(min_value=4, max_value=MAX_ELEMENT_LEN)) 53 | @example(0) 54 | @example(4) 55 | @example(10) 56 | @example(MAX_ELEMENT_LEN) 57 | def test_partial_to_packets(element, id_, max_len): 58 | packets = to_packets(max_len, element, id_, element.serialize()) 59 | partial = PartialElement.from_element(element, id_, max_len) 60 | assert partial.to_packets() == packets 61 | 62 | 63 | def test_partial_from_packet(element, id_, packets): 64 | for packet in packets: 65 | partial = PartialElement.from_packet(packet) 66 | assert partial.type_ == element.type_ 67 | assert partial.id_ == id_ 68 | assert packet.part_total == len(packets) 69 | assert len(partial) == 1 70 | assert partial.keys()[0] == packet.part_num 71 | 72 | 73 | def test_partial_from_packets(element, id_, packets): 74 | partial = None 75 | for packet in packets: 76 | if partial is None: 77 | partial = PartialElement.from_packet(packet) 78 | else: 79 | partial.add_packet(packet) 80 | assert partial[packet.part_num] == packet.payload 81 | assert partial.to_element() == element 82 | 83 | 84 | def test_incomplete_partial_from_packet(packets): 85 | if len(packets) > 1: 86 | partial = PartialElement.from_packet(packets[0]) 87 | with pytest.raises(errors.IncompleteElementError): 88 | str(partial) 89 | with pytest.raises(errors.IncompleteElementError): 90 | partial.to_packets() 91 | 92 | 93 | def test_element_id(id_): 94 | assert len(a2b(id_)) == ID_LENGTH 95 | 96 | 97 | def test_get_random_element_id(): 98 | id_0 = get_random_id() 99 | id_1 = get_random_id() 100 | assert id_1 != id_0 101 | 102 | 103 | @pytest.fixture 104 | def serialized_payload(content): 105 | return json.dumps({'content': content}) 106 | 107 | 108 | @pytest.fixture 109 | def file_size(content): 110 | return len(content) 111 | 112 | 113 | @pytest.fixture 114 | def file_checksum(content): 115 | return b2a(hash_(content)) 116 | 117 | 118 | @pytest.fixture 119 | def file_request_serialized_payload(content, file_checksum, file_size): 120 | return json.dumps({'content': content, 121 | 'checksum': file_checksum, 122 | 'size': file_size}) 123 | 124 | 125 | @pytest.fixture 126 | def element(content): 127 | return Element(content) 128 | 129 | 130 | @pytest.fixture 131 | def id_(): 132 | return get_random_id() 133 | 134 | 135 | MAX_ELEMENT_LENS = [0, 4, 10, MAX_ELEMENT_LEN] 136 | 137 | 138 | @pytest.fixture(params=MAX_ELEMENT_LENS) 139 | def packets(request, element, id_, serialized_payload): 140 | return to_packets(request.param, element, id_, serialized_payload) 141 | 142 | 143 | def to_packets(max_len, element, id_, serialized_payload): 144 | element_packets = list() 145 | part_len = max_len / 4 * 3 if max_len else len(serialized_payload) 146 | part_total = int(ceil(float(len(serialized_payload)) / part_len)) 147 | for part_num in range(part_total): 148 | start = part_num * part_len 149 | element_packets.append( 150 | ElementPacket(element.type_, id_, part_num, part_total, 151 | payload=serialized_payload[start:start+part_len])) 152 | return element_packets 153 | -------------------------------------------------------------------------------- /tests/test_file_transfer.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from twisted.internet.defer import Deferred 5 | 6 | from unmessage import elements 7 | from unmessage.peer import b2a, FileTransfer, MAX_ELEMENT_LEN 8 | from pyaxo import hash_ 9 | 10 | from .utils import remove_file 11 | 12 | 13 | @pytest.inlineCallbacks 14 | def test_send_file(out_content, out_hash, b64_out_hash, out_path, in_path, 15 | conversations, callback_side_effect): 16 | conv_a, conv_b = yield conversations 17 | 18 | d_req_in = Deferred() 19 | conv_b.ui.notify_in_file_request = callback_side_effect(d_req_in) 20 | d_file_out = Deferred() 21 | conv_a.ui.notify_finished_out_file = callback_side_effect(d_file_out) 22 | d_file_in = Deferred() 23 | conv_b.ui.notify_finished_in_file = callback_side_effect(d_file_in) 24 | 25 | yield conv_a.send_file(out_path) 26 | yield d_req_in 27 | yield conv_b.accept_file(b64_out_hash, in_path) 28 | yield d_file_out 29 | yield d_file_in 30 | 31 | in_content = open(in_path, 'r').read() 32 | assert in_content == out_content 33 | assert hash_(in_content) == out_hash 34 | 35 | 36 | @pytest.inlineCallbacks 37 | def test_prepare_file_request(out_path, request_element, conversations): 38 | conv_a, _ = yield conversations 39 | 40 | manager = conv_a.init_file() 41 | element, _ = manager.prepare_request(out_path) 42 | 43 | assert element == request_element 44 | 45 | 46 | @pytest.inlineCallbacks 47 | def test_prepare_file_accept(b64_out_hash, transfer, accept_element, 48 | conversations): 49 | _, conv_b = yield conversations 50 | 51 | manager = conv_b.init_file() 52 | manager.in_requests[b64_out_hash] = transfer 53 | element, _ = manager.prepare_accept(b64_out_hash) 54 | 55 | assert element == accept_element 56 | 57 | 58 | @pytest.inlineCallbacks 59 | def test_prepare_file(b64_out_hash, transfer, file_element, conversations): 60 | conv_a, _ = yield conversations 61 | 62 | manager = conv_a.init_file() 63 | manager.out_requests[b64_out_hash] = transfer 64 | element, _ = manager.prepare_file(b64_out_hash) 65 | 66 | assert element == file_element 67 | 68 | CONTENTS = {'short': lambda c: c, 69 | 'long': lambda c: MAX_ELEMENT_LEN * 2 / len(c) * c} 70 | 71 | 72 | @pytest.fixture(params=CONTENTS.values(), ids=CONTENTS.keys()) 73 | def out_content(request, content): 74 | return request.param(content) 75 | 76 | 77 | @pytest.fixture 78 | def b64_out_content(out_content): 79 | return b2a(out_content.decode('ascii')) 80 | 81 | 82 | @pytest.fixture 83 | def file_size(out_content): 84 | return len(out_content) 85 | 86 | 87 | @pytest.fixture 88 | def out_hash(out_content): 89 | return hash_(out_content) 90 | 91 | 92 | @pytest.fixture 93 | def b64_out_hash(out_hash): 94 | return b2a(out_hash) 95 | 96 | 97 | @pytest.fixture 98 | def out_path(): 99 | return '/tmp/unmessage-out-file.txt' 100 | 101 | 102 | @pytest.fixture 103 | def file_name(out_path): 104 | return os.path.split(out_path)[1] 105 | 106 | 107 | @pytest.fixture 108 | def request_element(file_name, file_size, out_hash): 109 | return elements.FileRequestElement(file_name, 110 | size=file_size, 111 | checksum=b2a(out_hash)) 112 | 113 | 114 | @pytest.fixture 115 | def accept_element(out_hash): 116 | return elements.FileRequestElement( 117 | elements.FileRequestElement.request_accepted, 118 | checksum=b2a(out_hash)) 119 | 120 | 121 | @pytest.fixture 122 | def file_element(out_content, b64_out_content): 123 | return elements.FileElement(b64_out_content) 124 | 125 | 126 | @pytest.fixture 127 | def transfer(request_element, b64_out_content): 128 | return FileTransfer(request_element, b64_out_content) 129 | 130 | 131 | @pytest.fixture 132 | def in_path(): 133 | return '/tmp/unmessage-in-file.txt' 134 | 135 | 136 | @pytest.fixture(autouse=True) 137 | def setup_teardown(out_content, out_path, in_path): 138 | open(out_path, 'w').write(out_content) 139 | remove_file(in_path) 140 | yield 141 | remove_file(out_path) 142 | remove_file(in_path) 143 | -------------------------------------------------------------------------------- /tests/test_packets.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from hypothesis import assume, example, given 4 | from hypothesis.strategies import binary, integers, text 5 | 6 | from nacl.utils import random 7 | from unmessage import errors 8 | from unmessage import packets 9 | from pyaxo import a2b, b2a 10 | from twisted.python.compat import nativeString 11 | 12 | from .utils import slow 13 | 14 | 15 | CORRECT_LEN_INTRO_DATA = random(1) 16 | CORRECT_LEN_IV = random(packets.IV_LEN) 17 | CORRECT_LEN_HASH = random(packets.HASH_LEN) 18 | CORRECT_LEN_KEY = random(packets.KEY_LEN) 19 | CORRECT_LEN_ENC_KEY = random(packets.ENC_KEY_LEN) 20 | CORRECT_LEN_PAYLOAD = random(1) 21 | CORRECT_LEN_HANDSHAKE_PACKET = random(1) 22 | CORRECT_LEN_IDENTITY = random(1) 23 | 24 | 25 | def is_ascii_encodable(value): 26 | try: 27 | nativeString(value) 28 | except (TypeError, UnicodeError): 29 | return False 30 | else: 31 | return True 32 | 33 | 34 | @given( 35 | text(), 36 | integers(), 37 | ) 38 | @example( 39 | b2a(random(1)), 40 | 1, 41 | ) 42 | @example( 43 | b2a(random(1)), 44 | 2, 45 | ) 46 | def test_is_valid_b64_length(value, length): 47 | assume(is_ascii_encodable(value)) 48 | 49 | ascii_value = nativeString(value) 50 | try: 51 | byte_value = a2b(ascii_value) 52 | except TypeError: 53 | assert not packets.is_valid_length(ascii_value, length) 54 | else: 55 | assert (packets.is_valid_length(ascii_value, length) is 56 | (len(byte_value) == length)) 57 | 58 | 59 | @given( 60 | text(), 61 | ) 62 | @example( 63 | b2a(random(1)), 64 | ) 65 | @example( 66 | '', 67 | ) 68 | def test_is_valid_non_empty_b64(value): 69 | assume(is_ascii_encodable(value)) 70 | 71 | ascii_value = nativeString(value) 72 | try: 73 | byte_value = a2b(ascii_value) 74 | except TypeError: 75 | assert not packets.is_valid_non_empty(ascii_value) 76 | else: 77 | assert packets.is_valid_non_empty(ascii_value) is (len(byte_value) > 0) 78 | 79 | 80 | LEN_VALIDATORS = {v[0].__name__: v 81 | for v in [(packets.is_valid_iv, packets.IV_LEN), 82 | (packets.is_valid_key, packets.KEY_LEN), 83 | (packets.is_valid_enc_key, packets.ENC_KEY_LEN), 84 | (packets.is_valid_hash, packets.HASH_LEN), 85 | (packets.is_valid_empty, 0)]} 86 | 87 | 88 | @slow 89 | @given( 90 | text(), 91 | ) 92 | @example( 93 | b2a(random(packets.IV_LEN)), 94 | ) 95 | @example( 96 | b2a(random(packets.KEY_LEN)), 97 | ) 98 | @example( 99 | b2a(random(packets.ENC_KEY_LEN)), 100 | ) 101 | @example( 102 | b2a(random(packets.HASH_LEN)), 103 | ) 104 | @example( 105 | '', 106 | ) 107 | @pytest.mark.parametrize(('validator', 'length'), 108 | LEN_VALIDATORS.values(), 109 | ids=LEN_VALIDATORS.keys()) 110 | def test_length_validators(validator, length, value): 111 | assume(is_ascii_encodable(value)) 112 | 113 | ascii_value = nativeString(value) 114 | try: 115 | byte_value = a2b(ascii_value) 116 | except TypeError: 117 | assert not validator(ascii_value) 118 | else: 119 | assert validator(ascii_value) is (len(byte_value) == length) 120 | 121 | 122 | def join_encode_data(lines): 123 | return packets.LINESEP.join([b2a(l) for l in lines]) 124 | 125 | 126 | VALID_INTRO_PARTS = [ 127 | CORRECT_LEN_IV, 128 | CORRECT_LEN_HASH, 129 | CORRECT_LEN_INTRO_DATA, 130 | ] 131 | 132 | 133 | @given( 134 | binary(), 135 | binary(), 136 | binary(), 137 | ) 138 | @example( 139 | *VALID_INTRO_PARTS 140 | ) 141 | def test_build_intro_packet(iv, 142 | iv_hash, 143 | data): 144 | data = join_encode_data([iv, 145 | iv_hash, 146 | data]) 147 | if (len(iv) == packets.IV_LEN and 148 | len(iv_hash) == packets.HASH_LEN and 149 | len(data)): 150 | assert isinstance(packets.IntroductionPacket.build(data), 151 | packets.IntroductionPacket) 152 | else: 153 | with pytest.raises(errors.MalformedPacketError): 154 | packets.IntroductionPacket.build(data) 155 | 156 | 157 | VALID_REGULAR_PARTS = [ 158 | CORRECT_LEN_IV, 159 | CORRECT_LEN_HASH, 160 | CORRECT_LEN_HASH, 161 | '', 162 | CORRECT_LEN_PAYLOAD, 163 | ] 164 | 165 | 166 | @given( 167 | binary(), 168 | binary(), 169 | binary(), 170 | binary(), 171 | binary(), 172 | ) 173 | @example( 174 | *VALID_REGULAR_PARTS 175 | ) 176 | def test_build_regular_packet(iv, 177 | iv_hash, 178 | payload_hash, 179 | handshake_key, 180 | payload): 181 | data = join_encode_data([iv, 182 | iv_hash, 183 | payload_hash, 184 | handshake_key, 185 | payload]) 186 | if (len(iv) == packets.IV_LEN and 187 | len(iv_hash) == packets.HASH_LEN and 188 | len(payload_hash) == packets.HASH_LEN and 189 | not len(handshake_key) and 190 | len(payload)): 191 | assert isinstance(packets.RegularPacket.build(data), 192 | packets.RegularPacket) 193 | else: 194 | with pytest.raises(errors.MalformedPacketError): 195 | packets.RegularPacket.build(data) 196 | 197 | 198 | VALID_REPLY_PARTS = [ 199 | CORRECT_LEN_IV, 200 | CORRECT_LEN_HASH, 201 | CORRECT_LEN_HASH, 202 | CORRECT_LEN_ENC_KEY, 203 | CORRECT_LEN_PAYLOAD, 204 | ] 205 | 206 | 207 | @given( 208 | binary(), 209 | binary(), 210 | binary(), 211 | binary(), 212 | binary(), 213 | ) 214 | @example( 215 | *VALID_REPLY_PARTS 216 | ) 217 | def test_build_reply_packet(iv, 218 | iv_hash, 219 | payload_hash, 220 | handshake_key, 221 | payload): 222 | data = join_encode_data([iv, 223 | iv_hash, 224 | payload_hash, 225 | handshake_key, 226 | payload]) 227 | if (len(iv) == packets.IV_LEN and 228 | len(iv_hash) == packets.HASH_LEN and 229 | len(payload_hash) == packets.HASH_LEN and 230 | len(handshake_key) == packets.ENC_KEY_LEN and 231 | len(payload)): 232 | assert isinstance(packets.ReplyPacket.build(data), 233 | packets.ReplyPacket) 234 | else: 235 | with pytest.raises(errors.MalformedPacketError): 236 | packets.ReplyPacket.build(data) 237 | 238 | 239 | VALID_REQUEST_PARTS = [ 240 | CORRECT_LEN_IV, 241 | CORRECT_LEN_HASH, 242 | CORRECT_LEN_HASH, 243 | CORRECT_LEN_KEY, 244 | CORRECT_LEN_HANDSHAKE_PACKET, 245 | ] 246 | 247 | 248 | @given( 249 | binary(), 250 | binary(), 251 | binary(), 252 | binary(), 253 | binary(), 254 | ) 255 | @example( 256 | *VALID_REQUEST_PARTS 257 | ) 258 | def test_build_request_packet(iv, 259 | iv_hash, 260 | handshake_packet_hash, 261 | request_key, 262 | handshake_packet): 263 | data = join_encode_data([iv, 264 | iv_hash, 265 | handshake_packet_hash, 266 | request_key, 267 | handshake_packet]) 268 | if (len(iv) == packets.IV_LEN and 269 | len(iv_hash) == packets.HASH_LEN and 270 | len(handshake_packet_hash) == packets.HASH_LEN and 271 | len(request_key) == packets.KEY_LEN and 272 | len(handshake_packet)): 273 | assert isinstance(packets.RequestPacket.build(data), 274 | packets.RequestPacket) 275 | else: 276 | with pytest.raises(errors.MalformedPacketError): 277 | packets.RequestPacket.build(data) 278 | 279 | 280 | VALID_HANDSHAKE_PARTS = [ 281 | CORRECT_LEN_IDENTITY, 282 | CORRECT_LEN_KEY, 283 | CORRECT_LEN_KEY, 284 | CORRECT_LEN_KEY, 285 | ] 286 | 287 | 288 | @given( 289 | binary(), 290 | binary(), 291 | binary(), 292 | binary(), 293 | ) 294 | @example( 295 | *VALID_HANDSHAKE_PARTS 296 | ) 297 | def test_build_handshake_packet(identity, 298 | identity_key, 299 | handshake_key, 300 | ratchet_key): 301 | data = join_encode_data([identity, 302 | identity_key, 303 | handshake_key, 304 | ratchet_key]) 305 | if (len(identity) and 306 | len(identity_key) == packets.KEY_LEN and 307 | len(handshake_key) == packets.KEY_LEN and 308 | len(ratchet_key) == packets.KEY_LEN): 309 | assert isinstance(packets.HandshakePacket.build(data), 310 | packets.HandshakePacket) 311 | else: 312 | with pytest.raises(errors.MalformedPacketError): 313 | packets.HandshakePacket.build(data) 314 | 315 | 316 | PACKET_TYPES = {v[0].__name__: v 317 | for v in [(packets.IntroductionPacket, VALID_INTRO_PARTS), 318 | (packets.RegularPacket, VALID_REGULAR_PARTS), 319 | (packets.ReplyPacket, VALID_REPLY_PARTS), 320 | (packets.RequestPacket, VALID_REQUEST_PARTS), 321 | (packets.HandshakePacket, VALID_HANDSHAKE_PARTS)]} 322 | 323 | 324 | @pytest.mark.parametrize(('packet_type', 'parts'), 325 | PACKET_TYPES.values(), 326 | ids=PACKET_TYPES.keys()) 327 | def test_build_pack(packet_type, parts): 328 | data = join_encode_data(parts) 329 | assert str(packet_type.build(data)) == data 330 | -------------------------------------------------------------------------------- /tests/test_peer.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | 3 | import pytest 4 | 5 | from unmessage.peer import ConversationPaths, PeerPaths 6 | 7 | 8 | @pytest.inlineCallbacks 9 | def test_start_stop(peer_a): 10 | notification = yield peer_a.start(local_mode=True) 11 | assert str(notification) == 'Peer started' 12 | yield peer_a.stop() 13 | 14 | 15 | def test_peer_paths(): 16 | base = 'base' 17 | peer_name = 'pytest' 18 | peer_base = join(base, peer_name) 19 | peer_paths = PeerPaths(base, peer_name) 20 | 21 | assert peer_paths.base == peer_base 22 | assert peer_paths.peer_db == join(peer_base, 'peer.db') 23 | assert peer_paths.axolotl_db == join(peer_base, 'axolotl.db') 24 | assert peer_paths.tor_dir.base == join(peer_base, 'tor') 25 | assert peer_paths.tor_data_dir == join(peer_paths.tor_dir.base, 'data') 26 | assert peer_paths.log_file == join(peer_base, 'peer.log') 27 | assert peer_paths.conversations_dir == join(peer_base, 'conversations') 28 | 29 | other_name = 'alice' 30 | conv_paths = ConversationPaths(peer_paths.conversations_dir, other_name) 31 | assert conv_paths.base == join(peer_paths.conversations_dir, other_name) 32 | assert (conv_paths.file_transfer_dir.base == 33 | join(conv_paths.base, 'file-transfer')) 34 | -------------------------------------------------------------------------------- /tests/test_serializable.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import attr 4 | import pytest 5 | 6 | from unmessage.utils import Serializable 7 | 8 | 9 | ATTRIBUTE = 'attribute' 10 | FILTERED_OUT_ATTRIBUTE = 'filtered_out_attribute' 11 | VALUE = 'value' 12 | 13 | 14 | def test_serialize(simple_serializable, serialized): 15 | assert simple_serializable.serialize() == serialized 16 | 17 | 18 | def test_deserialize(serialized): 19 | assert (SimpleSerializable.deserialize(serialized) == 20 | SimpleSerializable(VALUE)) 21 | 22 | 23 | def test_serialize_deserialize(simple_serializable): 24 | assert (SimpleSerializable.deserialize(simple_serializable.serialize()) == 25 | simple_serializable) 26 | 27 | 28 | def test_serialize_filtered(filtered_serializable, serialized): 29 | assert filtered_serializable.serialize() == serialized 30 | 31 | 32 | @attr.s 33 | class Attribute(object): 34 | name = attr.ib() 35 | 36 | 37 | ATTRIBUTES = {ATTRIBUTE: Attribute(ATTRIBUTE), 38 | FILTERED_OUT_ATTRIBUTE: Attribute(FILTERED_OUT_ATTRIBUTE)} 39 | 40 | 41 | @pytest.mark.parametrize('attribute', 42 | ATTRIBUTES.values(), 43 | ids=ATTRIBUTES.keys()) 44 | def test_filter(attribute, filtered_serializable): 45 | assert (filtered_serializable.filter_attrs(attribute) is 46 | (attribute.name in filtered_serializable.filtered_attr_names)) 47 | 48 | 49 | @attr.s 50 | class SimpleSerializable(Serializable): 51 | attribute = attr.ib() 52 | 53 | 54 | @pytest.fixture 55 | def simple_serializable(): 56 | return SimpleSerializable(VALUE) 57 | 58 | 59 | @attr.s 60 | class FilteredSerializable(SimpleSerializable): 61 | filtered_attr_names = [ATTRIBUTE] 62 | 63 | filtered_out_attribute = attr.ib(default=None) 64 | 65 | 66 | @pytest.fixture 67 | def filtered_serializable(): 68 | return FilteredSerializable(VALUE, VALUE) 69 | 70 | 71 | @pytest.fixture 72 | def serialized(): 73 | return json.dumps({ATTRIBUTE: VALUE}) 74 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | 3 | from unmessage.utils import Paths 4 | 5 | 6 | def test_paths_base(): 7 | head = 'head' 8 | tail = 'tail' 9 | paths = Paths(head, tail) 10 | assert paths.base == str(paths) 11 | assert paths.base == join(head, tail) 12 | 13 | 14 | def test_logger_fixture(log): 15 | info = 'test_utils.py::test_logger_fixture' 16 | log.info(info) 17 | with open('/tmp/unmessage.log', 'r') as f: 18 | last_log = f.readlines()[-1].strip() 19 | assert last_log.endswith(info) 20 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pytest 3 | from twisted.internet import defer 4 | 5 | from unmessage.peer import Peer, _ConversationProtocol 6 | 7 | 8 | slow = pytest.mark.slow 9 | slow_help = 'run slow tests' 10 | slow_option = '--run-slow' 11 | 12 | 13 | def remove_file(file_path): 14 | if os.path.exists(file_path): 15 | os.remove(file_path) 16 | 17 | 18 | def create_peer(name, reactor): 19 | return Peer(name, reactor) 20 | 21 | 22 | def create_connection(peer): 23 | return _ConversationProtocol(peer._twisted_factory) 24 | 25 | 26 | def fake_send(conn_in): 27 | def send(data): 28 | conn_in.stringReceived(data) 29 | return send 30 | 31 | 32 | def fake_connect(peer_in, peer_out, mocker): 33 | def connect(address): 34 | conn_in = create_connection(peer_in) 35 | conn_out = create_connection(peer_out) 36 | 37 | conn_in.transport = mocker.Mock() 38 | conn_out.transport = mocker.Mock() 39 | 40 | conn_in.transport.loseConnection = mocker.Mock( 41 | side_effect=lambda: conn_out.connectionLost(None)) 42 | conn_in.transport.loseConnection = mocker.Mock( 43 | side_effect=lambda: conn_in.connectionLost(None)) 44 | 45 | conn_in.send = mocker.Mock(side_effect=fake_send(conn_out)) 46 | conn_out.send = mocker.Mock(side_effect=fake_send(conn_in)) 47 | 48 | peer_in._add_intro_manager(conn_in) 49 | 50 | return defer.succeed(conn_out) 51 | return connect 52 | 53 | 54 | def attach(peer_x, peer_y, mocker): 55 | peer_x._connect = mocker.Mock(side_effect=fake_connect(peer_y, peer_x, 56 | mocker)) 57 | peer_y._connect = mocker.Mock(side_effect=fake_connect(peer_x, peer_y, 58 | mocker)) 59 | -------------------------------------------------------------------------------- /unmessage/__init__.py: -------------------------------------------------------------------------------- 1 | from ._version import get_versions 2 | from ._metadata import __author__, __contact__, __url__ 3 | from ._metadata import __license__, __copyright__ 4 | 5 | 6 | __version__ = get_versions()['version'] 7 | 8 | del get_versions 9 | -------------------------------------------------------------------------------- /unmessage/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import ConfigParser 3 | import sys 4 | 5 | from .cli import Cli 6 | from .errors import to_unmessage_error 7 | from .gui import Gui 8 | from .log import Logger 9 | from .peer import CONFIG, create_arg_parser 10 | 11 | 12 | log = Logger() 13 | 14 | 15 | def launch_cli(name=None, from_own_entry_point=True): 16 | if from_own_entry_point: 17 | log.debug('Launching unMessage from the `unmessage-cli` entry point') 18 | 19 | def create_ui(reactor): 20 | return Cli(reactor) 21 | 22 | launch(create_ui, name, add_remote_mode=True) 23 | 24 | 25 | def launch_gui(name=None, from_own_entry_point=True): 26 | if from_own_entry_point: 27 | log.debug('Launching unMessage from the `unmessage-gui` entry point') 28 | 29 | def create_ui(reactor): 30 | from twisted.internet import tksupport 31 | 32 | gui = Gui(reactor) 33 | tksupport.install(gui) 34 | return gui 35 | 36 | launch(create_ui, name, add_remote_mode=False) 37 | 38 | 39 | def launch(create_ui, name=None, add_remote_mode=False): 40 | from twisted.internet import reactor 41 | 42 | parser = create_arg_parser(name, add_remote_mode) 43 | args = parser.parse_args() 44 | ui = create_ui(reactor) 45 | 46 | log.debug('Launching the {ui} ', ui=type(ui).__name__) 47 | 48 | def errback(failure): 49 | e = to_unmessage_error(failure) 50 | print '{}: {}'.format(e.title, e.message) 51 | reactor.stop() 52 | 53 | d = ui.start(args.name, 54 | args.local_server_ip, 55 | args.local_server_port, 56 | args.connect_to_tor, 57 | args.tor_socks_port, 58 | args.tor_control_port, 59 | args.local_mode, 60 | args.remote_mode if add_remote_mode else False) 61 | reactor.callLater(0, lambda: d.addErrback(errback)) 62 | reactor.run() 63 | 64 | 65 | UIS = {Cli.__name__: launch_cli, 66 | Gui.__name__: launch_gui} 67 | 68 | 69 | def main(): 70 | log.debug('Launching unMessage from the `unmessage` entry point') 71 | try: 72 | ui_class = CONFIG.get('unMessage', 'ui') 73 | launch_ui = UIS[ui_class] 74 | except (ConfigParser.NoSectionError, KeyError): 75 | log.debug('A valid previously launched UI was not found') 76 | launch_ui = launch_gui 77 | else: 78 | log.debug('A previously launched UI was found') 79 | try: 80 | name = CONFIG.get('unMessage', 'name') 81 | except ConfigParser.NoSectionError: 82 | name = None 83 | launch_ui(name, from_own_entry_point=False) 84 | 85 | 86 | if __name__ == '__main__': 87 | sys.exit(main()) 88 | -------------------------------------------------------------------------------- /unmessage/_metadata.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Anemone Labs' 2 | __contact__ = 'anemone@anemone.me' 3 | __url__ = 'https://github.com/AnemoneLabs/unmessage' 4 | __license__ = 'GPLv3' 5 | __copyright__ = 'Copyright 2016-2017' 6 | -------------------------------------------------------------------------------- /unmessage/_version.py: -------------------------------------------------------------------------------- 1 | 2 | # This file helps to compute a version number in source trees obtained from 3 | # git-archive tarball (such as those provided by githubs download-from-tag 4 | # feature). Distribution tarballs (built by setup.py sdist) and build 5 | # directories (produced by setup.py build) will contain a much shorter file 6 | # that just contains the computed version number. 7 | 8 | # This file is released into the public domain. Generated by 9 | # versioneer-0.18 (https://github.com/warner/python-versioneer) 10 | 11 | """Git implementation of _version.py.""" 12 | 13 | import errno 14 | import os 15 | import re 16 | import subprocess 17 | import sys 18 | 19 | 20 | def get_keywords(): 21 | """Get the keywords needed to look up the version information.""" 22 | # these strings will be replaced by git during git-archive. 23 | # setup.py/versioneer.py will grep for the variable names, so they must 24 | # each be defined on a line of their own. _version.py will just call 25 | # get_keywords(). 26 | git_refnames = " (HEAD -> develop)" 27 | git_full = "d636168f041afcc765e4c6ffd30c8e2f3ad2f0ba" 28 | git_date = "2017-09-16 16:19:47 +0000" 29 | keywords = {"refnames": git_refnames, "full": git_full, "date": git_date} 30 | return keywords 31 | 32 | 33 | class VersioneerConfig: 34 | """Container for Versioneer configuration parameters.""" 35 | 36 | 37 | def get_config(): 38 | """Create, populate and return the VersioneerConfig() object.""" 39 | # these strings are filled in when 'setup.py versioneer' creates 40 | # _version.py 41 | cfg = VersioneerConfig() 42 | cfg.VCS = "git" 43 | cfg.style = "" 44 | cfg.tag_prefix = "" 45 | cfg.parentdir_prefix = "unmessage" 46 | cfg.versionfile_source = "unmessage/_version.py" 47 | cfg.verbose = False 48 | return cfg 49 | 50 | 51 | class NotThisMethod(Exception): 52 | """Exception raised if a method is not valid for the current scenario.""" 53 | 54 | 55 | LONG_VERSION_PY = {} 56 | HANDLERS = {} 57 | 58 | 59 | def register_vcs_handler(vcs, method): # decorator 60 | """Decorator to mark a method as the handler for a particular VCS.""" 61 | def decorate(f): 62 | """Store f in HANDLERS[vcs][method].""" 63 | if vcs not in HANDLERS: 64 | HANDLERS[vcs] = {} 65 | HANDLERS[vcs][method] = f 66 | return f 67 | return decorate 68 | 69 | 70 | def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, 71 | env=None): 72 | """Call the given command(s).""" 73 | assert isinstance(commands, list) 74 | p = None 75 | for c in commands: 76 | try: 77 | dispcmd = str([c] + args) 78 | # remember shell=False, so use git.cmd on windows, not just git 79 | p = subprocess.Popen([c] + args, cwd=cwd, env=env, 80 | stdout=subprocess.PIPE, 81 | stderr=(subprocess.PIPE if hide_stderr 82 | else None)) 83 | break 84 | except EnvironmentError: 85 | e = sys.exc_info()[1] 86 | if e.errno == errno.ENOENT: 87 | continue 88 | if verbose: 89 | print("unable to run %s" % dispcmd) 90 | print(e) 91 | return None, None 92 | else: 93 | if verbose: 94 | print("unable to find command, tried %s" % (commands,)) 95 | return None, None 96 | stdout = p.communicate()[0].strip() 97 | if sys.version_info[0] >= 3: 98 | stdout = stdout.decode() 99 | if p.returncode != 0: 100 | if verbose: 101 | print("unable to run %s (error)" % dispcmd) 102 | print("stdout was %s" % stdout) 103 | return None, p.returncode 104 | return stdout, p.returncode 105 | 106 | 107 | def versions_from_parentdir(parentdir_prefix, root, verbose): 108 | """Try to determine the version from the parent directory name. 109 | 110 | Source tarballs conventionally unpack into a directory that includes both 111 | the project name and a version string. We will also support searching up 112 | two directory levels for an appropriately named parent directory 113 | """ 114 | rootdirs = [] 115 | 116 | for i in range(3): 117 | dirname = os.path.basename(root) 118 | if dirname.startswith(parentdir_prefix): 119 | return {"version": dirname[len(parentdir_prefix):], 120 | "full-revisionid": None, 121 | "dirty": False, "error": None, "date": None} 122 | else: 123 | rootdirs.append(root) 124 | root = os.path.dirname(root) # up a level 125 | 126 | if verbose: 127 | print("Tried directories %s but none started with prefix %s" % 128 | (str(rootdirs), parentdir_prefix)) 129 | raise NotThisMethod("rootdir doesn't start with parentdir_prefix") 130 | 131 | 132 | @register_vcs_handler("git", "get_keywords") 133 | def git_get_keywords(versionfile_abs): 134 | """Extract version information from the given file.""" 135 | # the code embedded in _version.py can just fetch the value of these 136 | # keywords. When used from setup.py, we don't want to import _version.py, 137 | # so we do it with a regexp instead. This function is not used from 138 | # _version.py. 139 | keywords = {} 140 | try: 141 | f = open(versionfile_abs, "r") 142 | for line in f.readlines(): 143 | if line.strip().startswith("git_refnames ="): 144 | mo = re.search(r'=\s*"(.*)"', line) 145 | if mo: 146 | keywords["refnames"] = mo.group(1) 147 | if line.strip().startswith("git_full ="): 148 | mo = re.search(r'=\s*"(.*)"', line) 149 | if mo: 150 | keywords["full"] = mo.group(1) 151 | if line.strip().startswith("git_date ="): 152 | mo = re.search(r'=\s*"(.*)"', line) 153 | if mo: 154 | keywords["date"] = mo.group(1) 155 | f.close() 156 | except EnvironmentError: 157 | pass 158 | return keywords 159 | 160 | 161 | @register_vcs_handler("git", "keywords") 162 | def git_versions_from_keywords(keywords, tag_prefix, verbose): 163 | """Get version information from git keywords.""" 164 | if not keywords: 165 | raise NotThisMethod("no keywords at all, weird") 166 | date = keywords.get("date") 167 | if date is not None: 168 | # git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant 169 | # datestamp. However we prefer "%ci" (which expands to an "ISO-8601 170 | # -like" string, which we must then edit to make compliant), because 171 | # it's been around since git-1.5.3, and it's too difficult to 172 | # discover which version we're using, or to work around using an 173 | # older one. 174 | date = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 175 | refnames = keywords["refnames"].strip() 176 | if refnames.startswith("$Format"): 177 | if verbose: 178 | print("keywords are unexpanded, not using") 179 | raise NotThisMethod("unexpanded keywords, not a git-archive tarball") 180 | refs = set([r.strip() for r in refnames.strip("()").split(",")]) 181 | # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of 182 | # just "foo-1.0". If we see a "tag: " prefix, prefer those. 183 | TAG = "tag: " 184 | tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)]) 185 | if not tags: 186 | # Either we're using git < 1.8.3, or there really are no tags. We use 187 | # a heuristic: assume all version tags have a digit. The old git %d 188 | # expansion behaves like git log --decorate=short and strips out the 189 | # refs/heads/ and refs/tags/ prefixes that would let us distinguish 190 | # between branches and tags. By ignoring refnames without digits, we 191 | # filter out many common branch names like "release" and 192 | # "stabilization", as well as "HEAD" and "master". 193 | tags = set([r for r in refs if re.search(r'\d', r)]) 194 | if verbose: 195 | print("discarding '%s', no digits" % ",".join(refs - tags)) 196 | if verbose: 197 | print("likely tags: %s" % ",".join(sorted(tags))) 198 | for ref in sorted(tags): 199 | # sorting will prefer e.g. "2.0" over "2.0rc1" 200 | if ref.startswith(tag_prefix): 201 | r = ref[len(tag_prefix):] 202 | if verbose: 203 | print("picking %s" % r) 204 | return {"version": r, 205 | "full-revisionid": keywords["full"].strip(), 206 | "dirty": False, "error": None, 207 | "date": date} 208 | # no suitable tags, so version is "0+unknown", but full hex is still there 209 | if verbose: 210 | print("no suitable tags, using unknown + full revision id") 211 | return {"version": "0+unknown", 212 | "full-revisionid": keywords["full"].strip(), 213 | "dirty": False, "error": "no suitable tags", "date": None} 214 | 215 | 216 | @register_vcs_handler("git", "pieces_from_vcs") 217 | def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command): 218 | """Get version from 'git describe' in the root of the source tree. 219 | 220 | This only gets called if the git-archive 'subst' keywords were *not* 221 | expanded, and _version.py hasn't already been rewritten with a short 222 | version string, meaning we're inside a checked out source tree. 223 | """ 224 | GITS = ["git"] 225 | if sys.platform == "win32": 226 | GITS = ["git.cmd", "git.exe"] 227 | 228 | out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root, 229 | hide_stderr=True) 230 | if rc != 0: 231 | if verbose: 232 | print("Directory %s not under git control" % root) 233 | raise NotThisMethod("'git rev-parse --git-dir' returned error") 234 | 235 | # if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty] 236 | # if there isn't one, this yields HEX[-dirty] (no NUM) 237 | describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty", 238 | "--always", "--long", 239 | "--match", "%s*" % tag_prefix], 240 | cwd=root) 241 | # --long was added in git-1.5.5 242 | if describe_out is None: 243 | raise NotThisMethod("'git describe' failed") 244 | describe_out = describe_out.strip() 245 | full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root) 246 | if full_out is None: 247 | raise NotThisMethod("'git rev-parse' failed") 248 | full_out = full_out.strip() 249 | 250 | pieces = {} 251 | pieces["long"] = full_out 252 | pieces["short"] = full_out[:7] # maybe improved later 253 | pieces["error"] = None 254 | 255 | # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty] 256 | # TAG might have hyphens. 257 | git_describe = describe_out 258 | 259 | # look for -dirty suffix 260 | dirty = git_describe.endswith("-dirty") 261 | pieces["dirty"] = dirty 262 | if dirty: 263 | git_describe = git_describe[:git_describe.rindex("-dirty")] 264 | 265 | # now we have TAG-NUM-gHEX or HEX 266 | 267 | if "-" in git_describe: 268 | # TAG-NUM-gHEX 269 | mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe) 270 | if not mo: 271 | # unparseable. Maybe git-describe is misbehaving? 272 | pieces["error"] = ("unable to parse git-describe output: '%s'" 273 | % describe_out) 274 | return pieces 275 | 276 | # tag 277 | full_tag = mo.group(1) 278 | if not full_tag.startswith(tag_prefix): 279 | if verbose: 280 | fmt = "tag '%s' doesn't start with prefix '%s'" 281 | print(fmt % (full_tag, tag_prefix)) 282 | pieces["error"] = ("tag '%s' doesn't start with prefix '%s'" 283 | % (full_tag, tag_prefix)) 284 | return pieces 285 | pieces["closest-tag"] = full_tag[len(tag_prefix):] 286 | 287 | # distance: number of commits since tag 288 | pieces["distance"] = int(mo.group(2)) 289 | 290 | # commit: short hex revision ID 291 | pieces["short"] = mo.group(3) 292 | 293 | else: 294 | # HEX: no tags 295 | pieces["closest-tag"] = None 296 | count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"], 297 | cwd=root) 298 | pieces["distance"] = int(count_out) # total number of commits 299 | 300 | # commit date: see ISO-8601 comment in git_versions_from_keywords() 301 | date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"], 302 | cwd=root)[0].strip() 303 | pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1) 304 | 305 | return pieces 306 | 307 | 308 | def plus_or_dot(pieces): 309 | """Return a + if we don't already have one, else return a .""" 310 | if "+" in pieces.get("closest-tag", ""): 311 | return "." 312 | return "+" 313 | 314 | 315 | def render_pep440(pieces): 316 | """Build up version string, with post-release "local version identifier". 317 | 318 | Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you 319 | get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty 320 | 321 | Exceptions: 322 | 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty] 323 | """ 324 | if pieces["closest-tag"]: 325 | rendered = pieces["closest-tag"] 326 | if pieces["distance"] or pieces["dirty"]: 327 | rendered += plus_or_dot(pieces) 328 | rendered += "%d.g%s" % (pieces["distance"], pieces["short"]) 329 | if pieces["dirty"]: 330 | rendered += ".dirty" 331 | else: 332 | # exception #1 333 | rendered = "0+untagged.%d.g%s" % (pieces["distance"], 334 | pieces["short"]) 335 | if pieces["dirty"]: 336 | rendered += ".dirty" 337 | return rendered 338 | 339 | 340 | def render_pep440_pre(pieces): 341 | """TAG[.post.devDISTANCE] -- No -dirty. 342 | 343 | Exceptions: 344 | 1: no tags. 0.post.devDISTANCE 345 | """ 346 | if pieces["closest-tag"]: 347 | rendered = pieces["closest-tag"] 348 | if pieces["distance"]: 349 | rendered += ".post.dev%d" % pieces["distance"] 350 | else: 351 | # exception #1 352 | rendered = "0.post.dev%d" % pieces["distance"] 353 | return rendered 354 | 355 | 356 | def render_pep440_post(pieces): 357 | """TAG[.postDISTANCE[.dev0]+gHEX] . 358 | 359 | The ".dev0" means dirty. Note that .dev0 sorts backwards 360 | (a dirty tree will appear "older" than the corresponding clean one), 361 | but you shouldn't be releasing software with -dirty anyways. 362 | 363 | Exceptions: 364 | 1: no tags. 0.postDISTANCE[.dev0] 365 | """ 366 | if pieces["closest-tag"]: 367 | rendered = pieces["closest-tag"] 368 | if pieces["distance"] or pieces["dirty"]: 369 | rendered += ".post%d" % pieces["distance"] 370 | if pieces["dirty"]: 371 | rendered += ".dev0" 372 | rendered += plus_or_dot(pieces) 373 | rendered += "g%s" % pieces["short"] 374 | else: 375 | # exception #1 376 | rendered = "0.post%d" % pieces["distance"] 377 | if pieces["dirty"]: 378 | rendered += ".dev0" 379 | rendered += "+g%s" % pieces["short"] 380 | return rendered 381 | 382 | 383 | def render_pep440_old(pieces): 384 | """TAG[.postDISTANCE[.dev0]] . 385 | 386 | The ".dev0" means dirty. 387 | 388 | Eexceptions: 389 | 1: no tags. 0.postDISTANCE[.dev0] 390 | """ 391 | if pieces["closest-tag"]: 392 | rendered = pieces["closest-tag"] 393 | if pieces["distance"] or pieces["dirty"]: 394 | rendered += ".post%d" % pieces["distance"] 395 | if pieces["dirty"]: 396 | rendered += ".dev0" 397 | else: 398 | # exception #1 399 | rendered = "0.post%d" % pieces["distance"] 400 | if pieces["dirty"]: 401 | rendered += ".dev0" 402 | return rendered 403 | 404 | 405 | def render_git_describe(pieces): 406 | """TAG[-DISTANCE-gHEX][-dirty]. 407 | 408 | Like 'git describe --tags --dirty --always'. 409 | 410 | Exceptions: 411 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 412 | """ 413 | if pieces["closest-tag"]: 414 | rendered = pieces["closest-tag"] 415 | if pieces["distance"]: 416 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 417 | else: 418 | # exception #1 419 | rendered = pieces["short"] 420 | if pieces["dirty"]: 421 | rendered += "-dirty" 422 | return rendered 423 | 424 | 425 | def render_git_describe_long(pieces): 426 | """TAG-DISTANCE-gHEX[-dirty]. 427 | 428 | Like 'git describe --tags --dirty --always -long'. 429 | The distance/hash is unconditional. 430 | 431 | Exceptions: 432 | 1: no tags. HEX[-dirty] (note: no 'g' prefix) 433 | """ 434 | if pieces["closest-tag"]: 435 | rendered = pieces["closest-tag"] 436 | rendered += "-%d-g%s" % (pieces["distance"], pieces["short"]) 437 | else: 438 | # exception #1 439 | rendered = pieces["short"] 440 | if pieces["dirty"]: 441 | rendered += "-dirty" 442 | return rendered 443 | 444 | 445 | def render(pieces, style): 446 | """Render the given version pieces into the requested style.""" 447 | if pieces["error"]: 448 | return {"version": "unknown", 449 | "full-revisionid": pieces.get("long"), 450 | "dirty": None, 451 | "error": pieces["error"], 452 | "date": None} 453 | 454 | if not style or style == "default": 455 | style = "pep440" # the default 456 | 457 | if style == "pep440": 458 | rendered = render_pep440(pieces) 459 | elif style == "pep440-pre": 460 | rendered = render_pep440_pre(pieces) 461 | elif style == "pep440-post": 462 | rendered = render_pep440_post(pieces) 463 | elif style == "pep440-old": 464 | rendered = render_pep440_old(pieces) 465 | elif style == "git-describe": 466 | rendered = render_git_describe(pieces) 467 | elif style == "git-describe-long": 468 | rendered = render_git_describe_long(pieces) 469 | else: 470 | raise ValueError("unknown style '%s'" % style) 471 | 472 | return {"version": rendered, "full-revisionid": pieces["long"], 473 | "dirty": pieces["dirty"], "error": None, 474 | "date": pieces.get("date")} 475 | 476 | 477 | def get_versions(): 478 | """Get version information or return default if unable to do so.""" 479 | # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have 480 | # __file__, we can work backwards from there to the root. Some 481 | # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which 482 | # case we can only use expanded keywords. 483 | 484 | cfg = get_config() 485 | verbose = cfg.verbose 486 | 487 | try: 488 | return git_versions_from_keywords(get_keywords(), cfg.tag_prefix, 489 | verbose) 490 | except NotThisMethod: 491 | pass 492 | 493 | try: 494 | root = os.path.realpath(__file__) 495 | # versionfile_source is the relative path from the top of the source 496 | # tree (where the .git directory might live) to this file. Invert 497 | # this to find the root from __file__. 498 | for i in cfg.versionfile_source.split('/'): 499 | root = os.path.dirname(root) 500 | except NameError: 501 | return {"version": "0+unknown", "full-revisionid": None, 502 | "dirty": None, 503 | "error": "unable to find root of source tree", 504 | "date": None} 505 | 506 | try: 507 | pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose) 508 | return render(pieces, cfg.style) 509 | except NotThisMethod: 510 | pass 511 | 512 | try: 513 | if cfg.parentdir_prefix: 514 | return versions_from_parentdir(cfg.parentdir_prefix, root, verbose) 515 | except NotThisMethod: 516 | pass 517 | 518 | return {"version": "0+unknown", "full-revisionid": None, 519 | "dirty": None, 520 | "error": "unable to compute version", "date": None} 521 | -------------------------------------------------------------------------------- /unmessage/contact.py: -------------------------------------------------------------------------------- 1 | import attr 2 | 3 | from .utils import Address, raise_invalid_identity, raise_invalid_pub_key 4 | 5 | 6 | @attr.s 7 | class Contact(object): 8 | identity = attr.ib(validator=raise_invalid_identity) 9 | key = attr.ib(validator=raise_invalid_pub_key) 10 | is_verified = attr.ib(validator=attr.validators.instance_of(bool), 11 | default=False) 12 | has_presence = attr.ib(validator=attr.validators.instance_of(bool), 13 | default=False) 14 | 15 | @property 16 | def name(self): 17 | return self.identity.split('@')[0] 18 | 19 | @property 20 | def address(self): 21 | host, port = self.identity.split('@')[-1].split(':') 22 | return Address(host, int(port)) 23 | -------------------------------------------------------------------------------- /unmessage/elements.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | import attr 4 | from nacl.utils import random 5 | from pyaxo import b2a 6 | 7 | from . import errors 8 | from .packets import ElementPacket 9 | from .utils import Serializable 10 | 11 | 12 | def raise_incomplete(f): 13 | @wraps(f) 14 | def wrapped_f(self, *args, **kwargs): 15 | if self.is_complete: 16 | return f(self, *args, **kwargs) 17 | else: 18 | raise errors.IncompleteElementError() 19 | return wrapped_f 20 | 21 | 22 | @attr.s 23 | class PartialElement(dict): 24 | type_ = attr.ib(validator=attr.validators.instance_of(str)) 25 | id_ = attr.ib(validator=attr.validators.instance_of(str)) 26 | part_total = attr.ib(validator=attr.validators.instance_of(int)) 27 | sender = attr.ib( 28 | validator=attr.validators.optional(attr.validators.instance_of(str)), 29 | default=None) 30 | receiver = attr.ib( 31 | validator=attr.validators.optional(attr.validators.instance_of(str)), 32 | default=None) 33 | 34 | @classmethod 35 | def from_element(cls, element, id_=None, max_len=0): 36 | serialized_element = element.serialize() 37 | id_ = id_ or get_random_id() 38 | max_len = max_len / 4 * 3 if max_len else len(serialized_element) 39 | 40 | part_num = 0 41 | partial = cls(element.type_, id_, part_num, 42 | element.sender, element.receiver) 43 | while len(serialized_element): 44 | partial.add_part(part_num, serialized_element[:max_len]) 45 | serialized_element = serialized_element[max_len:] 46 | part_num += 1 47 | partial.part_total = part_num 48 | 49 | return partial 50 | 51 | @classmethod 52 | def from_packet(cls, packet, sender=None, receiver=None): 53 | partial = cls(packet.type_, packet.id_, packet.part_total, 54 | sender, receiver) 55 | partial.add_packet(packet) 56 | return partial 57 | 58 | @raise_incomplete 59 | def __str__(self): 60 | return ''.join(self.values()) 61 | 62 | @property 63 | def is_complete(self): 64 | return len(self) == self.part_total 65 | 66 | def add_packet(self, packet): 67 | self.add_part(packet.part_num, packet.payload) 68 | 69 | def add_part(self, part_num, part): 70 | self[part_num] = part 71 | 72 | @raise_incomplete 73 | def to_packets(self): 74 | packets = list() 75 | for part_num, part in self.items(): 76 | packets.append(ElementPacket(self.type_, 77 | self.id_, 78 | part_num, 79 | self.part_total, 80 | part)) 81 | return packets 82 | 83 | def to_element(self): 84 | element = Element.build(self.type_, str(self)) 85 | element.sender = self.sender 86 | element.receiver = self.receiver 87 | return element 88 | 89 | 90 | @attr.s 91 | class Element(Serializable): 92 | filtered_attr_names = ['content'] 93 | 94 | element_classes = None 95 | 96 | type_ = 'elmt' 97 | 98 | content = attr.ib(default=None) 99 | sender = attr.ib(default=None) 100 | receiver = attr.ib(default=None) 101 | 102 | @classmethod 103 | def build(cls, type_, data): 104 | try: 105 | element_class = cls.get_element_classes()[type_] 106 | except KeyError: 107 | raise errors.UnknownElementError(type_) 108 | else: 109 | return element_class.deserialize(data) 110 | 111 | @classmethod 112 | def get_element_classes(cls): 113 | if not cls.element_classes: 114 | cls.element_classes = {c.type_: c 115 | for c in cls.__subclasses__() + [cls]} 116 | return cls.element_classes 117 | 118 | def __str__(self): 119 | return self.content 120 | 121 | 122 | @attr.s 123 | class RequestElement(Element): 124 | type_ = 'req' 125 | request_accepted = 'accepted' 126 | 127 | 128 | @attr.s 129 | class UntalkElement(Element): 130 | type_ = 'untalk' 131 | 132 | 133 | @attr.s 134 | class PresenceElement(Element): 135 | type_ = 'pres' 136 | status_online = 'online' 137 | status_offline = 'offline' 138 | 139 | 140 | @attr.s 141 | class MessageElement(Element): 142 | type_ = 'msg' 143 | 144 | 145 | @attr.s 146 | class AuthenticationElement(Element): 147 | type_ = 'auth' 148 | 149 | 150 | @attr.s 151 | class FileRequestElement(Element): 152 | filtered_attr_names = 'content size checksum'.split() 153 | 154 | type_ = 'filereq' 155 | request_accepted = 'accepted' 156 | 157 | size = attr.ib(default=None) 158 | checksum = attr.ib(default=None) 159 | 160 | @classmethod 161 | def is_valid_request(cls, element): 162 | # TODO improve this validator 163 | return ( 164 | isinstance(element, cls) and 165 | isinstance(element.content, unicode) and len(element.content) and 166 | isinstance(element.size, int) and element.size > 0 and 167 | isinstance(element.checksum, unicode) and len(element.checksum)) 168 | 169 | @classmethod 170 | def is_valid_accept(cls, element): 171 | # TODO improve this validator 172 | return ( 173 | isinstance(element, cls) and 174 | isinstance(element.content, unicode) and len(element.content) and 175 | element.size is None and 176 | isinstance(element.checksum, unicode) and len(element.checksum)) 177 | 178 | 179 | @attr.s 180 | class FileElement(Element): 181 | type_ = 'file' 182 | 183 | @classmethod 184 | def is_valid_file(cls, element): 185 | # TODO improve this validator 186 | return (isinstance(element, cls) and 187 | isinstance(element.content, unicode) and len(element.content)) 188 | 189 | 190 | REGULAR_ELEMENT_TYPES = [RequestElement.type_, 191 | PresenceElement.type_, 192 | MessageElement.type_, 193 | AuthenticationElement.type_] 194 | 195 | 196 | ID_LENGTH = 2 197 | 198 | 199 | def get_random_id(): 200 | return b2a(random(ID_LENGTH)) 201 | -------------------------------------------------------------------------------- /unmessage/errors.py: -------------------------------------------------------------------------------- 1 | from twisted.python.failure import Failure 2 | 3 | 4 | class UnmessageError(Exception): 5 | def __init__(self, message, title=None): 6 | super(UnmessageError, self).__init__() 7 | self.title = title or 'unMessage error' 8 | self.message = message 9 | 10 | def __str__(self): 11 | return self.message 12 | 13 | 14 | class UntalkError(UnmessageError): 15 | def __init__(self, message, title=None): 16 | super(UntalkError, self).__init__( 17 | message, 18 | title or 'unTalk error') 19 | 20 | 21 | class ConnectionLostError(UnmessageError): 22 | def __init__(self, contact): 23 | super(ConnectionLostError, self).__init__( 24 | title='Connection lost', 25 | message='The connection to {} has been lost'.format(contact)) 26 | 27 | 28 | class CorruptedPacketError(UnmessageError): 29 | def __init__(self, packet_type): 30 | super(CorruptedPacketError, self).__init__( 31 | title='Corrupted packet error', 32 | message='The packet integrity check failed') 33 | 34 | 35 | class OfflinePeerError(UnmessageError): 36 | def __init__(self, title, contact, is_request=False): 37 | message = "{} is offline".format(contact) 38 | if is_request: 39 | message += ' or such Onion Service does not exist' 40 | 41 | super(OfflinePeerError, self).__init__( 42 | title=title, 43 | message=message) 44 | 45 | 46 | class InactiveConversationError(UnmessageError): 47 | def __init__(self, contact): 48 | super(InactiveConversationError, self).__init__( 49 | title='Inactive conversation error', 50 | message=('This feature cannot be used without a connection to ' 51 | '{}'.format(contact))) 52 | 53 | 54 | class IncompleteElementError(UnmessageError): 55 | def __init__(self): 56 | super(IncompleteElementError, self).__init__( 57 | title='Incomplete element error', 58 | message=('The partial element does not contain enough parts to ' 59 | 'create a complete element')) 60 | 61 | 62 | class InvalidElementError(UnmessageError): 63 | def __init__(self): 64 | super(InvalidElementError, self).__init__( 65 | title='Invalid element error', 66 | message='The element provided is not valid') 67 | 68 | 69 | class InvalidFileNameError(UnmessageError): 70 | def __init__(self): 71 | super(InvalidFileNameError, self).__init__( 72 | title='Invalid file name error', 73 | message='The file name provided is not valid') 74 | 75 | 76 | class InvalidIdentityError(UnmessageError): 77 | def __init__(self): 78 | super(InvalidIdentityError, self).__init__( 79 | title='Value error', 80 | message='The identity provided is not valid') 81 | 82 | 83 | class InvalidPrivateKeyError(UnmessageError): 84 | def __init__(self): 85 | super(InvalidPrivateKeyError, self).__init__( 86 | title='Value error', 87 | message='The private key provided is not valid') 88 | 89 | 90 | class InvalidPublicKeyError(UnmessageError): 91 | def __init__(self): 92 | super(InvalidPublicKeyError, self).__init__( 93 | title='Value error', 94 | message='The public key provided is not valid') 95 | 96 | 97 | class InvalidSharedKeyError(UnmessageError): 98 | def __init__(self): 99 | super(InvalidSharedKeyError, self).__init__( 100 | title='Value error', 101 | message='The shared key provided is not valid') 102 | 103 | 104 | class InvalidNameError(UnmessageError): 105 | def __init__(self): 106 | super(InvalidNameError, self).__init__( 107 | title='Value error', 108 | message='The name provided is not valid') 109 | 110 | 111 | class MalformedPacketError(UnmessageError): 112 | def __init__(self, packet_type): 113 | super(MalformedPacketError, self).__init__( 114 | title='Malformed packet error', 115 | message='The data provided cannot be used to build a ' 116 | '{} packet'.format(packet_type)) 117 | 118 | 119 | class ManagerNotFoundError(UnmessageError): 120 | def __init__(self, type_): 121 | super(ManagerNotFoundError, self).__init__( 122 | title='Manager not found error', 123 | message='A manager for {} could not be found'.format(type_)) 124 | 125 | 126 | class TransportError(UnmessageError): 127 | def __init__(self, message): 128 | super(TransportError, self).__init__( 129 | title='Transport error', 130 | message=message) 131 | 132 | 133 | class UnknownContactError(UnmessageError): 134 | def __init__(self, contact): 135 | super(UnknownContactError, self).__init__( 136 | title='Unknown contact error', 137 | message='There is no contact called "{}"'.format(contact)) 138 | 139 | 140 | class UnknownElementError(UnmessageError): 141 | def __init__(self, type_): 142 | super(UnknownElementError, self).__init__( 143 | title='Unknown element error', 144 | message='The element type "{}" is unknown'.format(type_)) 145 | 146 | 147 | class VerificationError(UnmessageError): 148 | def __init__(self, contact): 149 | super(VerificationError, self).__init__( 150 | title='Key verification error', 151 | message="The key provided does not match {}'s!".format(contact)) 152 | 153 | 154 | def to_unmessage_error(error): 155 | e = error.value if isinstance(error, Failure) else error 156 | if isinstance(e, UnmessageError): 157 | return e 158 | else: 159 | return UnmessageError(message=str(e), title=type(e).__name__) 160 | -------------------------------------------------------------------------------- /unmessage/gui.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import Queue 3 | import Tkinter as Tk 4 | import ttk 5 | from functools import wraps 6 | from tkMessageBox import askyesno, showerror, showinfo 7 | from tkSimpleDialog import askstring 8 | 9 | from pyaxo import b2a 10 | from twisted.internet.defer import inlineCallbacks, returnValue 11 | 12 | from . import errors 13 | from .log import loggerFor, LogLevel 14 | from .notifications import UnmessageNotification 15 | from .peer import APP_NAME, Peer 16 | from .ui import ConversationUi, PeerUi 17 | from .ui import displays_error as _displays_error 18 | from .ui import displays_result as _displays_result 19 | 20 | 21 | def threadsafe(f): 22 | @wraps(f) 23 | def threadsafe_f(self, *args, **kwargs): 24 | self.calls_queue.put([f, self, args, kwargs]) 25 | return threadsafe_f 26 | 27 | 28 | def write_on_text(text, content, clear=True): 29 | state = text.cget('state') 30 | text.config(state=Tk.NORMAL) 31 | if clear: 32 | text.delete(1.0, Tk.END) 33 | for c in content: 34 | text.insert(Tk.INSERT, c) 35 | text.config(state=state) 36 | 37 | 38 | def displays_error(f): 39 | def display(self, error): 40 | self.display_error(message=str(error), 41 | title=error.title) 42 | return _displays_error(f, display) 43 | 44 | 45 | def displays_result(f): 46 | def display(self, result): 47 | if isinstance(result, UnmessageNotification): 48 | title = result.title 49 | else: 50 | title = None 51 | self.display_info(message=str(result), 52 | title=title) 53 | return _displays_result(f, display) 54 | 55 | 56 | class Gui(Tk.Tk, PeerUi): 57 | def __init__(self, reactor): 58 | super(Gui, self).__init__() 59 | 60 | self.log = loggerFor(self) 61 | 62 | self.reactor = reactor 63 | 64 | self.protocol('WM_DELETE_WINDOW', self.stop) 65 | 66 | self.calls_queue = Queue.Queue() 67 | self.title(APP_NAME) 68 | self.peer = None 69 | 70 | frame_notebook = Tk.Frame(self) 71 | frame_notebook.pack() 72 | 73 | self.notebook = ttk.Notebook(frame_notebook) 74 | self.notebook.pack() 75 | self.notebook.pack(fill=Tk.BOTH, expand=True) 76 | 77 | # hidden tab used only to set the size of the window 78 | hidden_tab = ChatTab(parent=self.notebook, 79 | gui=self, 80 | peer=self.peer, 81 | conversation=None) 82 | self.notebook.add(hidden_tab, text='Hidden') 83 | self.notebook.hide(hidden_tab) 84 | 85 | self.bootstrap_tab = BootstrapTab(parent=self.notebook, gui=self) 86 | 87 | self.menu_bar = Tk.Menu(self) 88 | self.menu_bar.add_command(label='New Chat', state=Tk.DISABLED, 89 | command=self.create_request) 90 | self.menu_bar.add_command(label='Copy Identity', state=Tk.DISABLED, 91 | command=self.copy_identity) 92 | self.menu_bar.add_command(label='Copy Key', state=Tk.DISABLED, 93 | command=self.copy_key) 94 | self.menu_bar.add_command(label='Copy Peer', state=Tk.DISABLED, 95 | command=self.copy_peer) 96 | self.menu_bar.add_command(label='Copy Onion', state=Tk.DISABLED, 97 | command=self.copy_onion) 98 | self.menu_bar.add_command(label='Quit', command=self.stop) 99 | self.config(menu=self.menu_bar) 100 | 101 | self.check_calls() 102 | 103 | def check_calls(self): 104 | try: 105 | while 1: 106 | method, instance, args, kwargs = self.calls_queue.get_nowait() 107 | method(instance, *args, **kwargs) 108 | self.update_idletasks() 109 | except Queue.Empty: 110 | pass 111 | self.after(100, self.check_calls) 112 | 113 | @inlineCallbacks 114 | def start(self, name, 115 | local_server_ip=None, 116 | local_server_port=None, 117 | launch_tor=True, 118 | tor_socks_port=None, 119 | tor_control_port=None, 120 | local_mode=False, 121 | remote_mode=False): 122 | if name: 123 | yield self.init_peer(name, 124 | local_server_ip, 125 | local_server_port, 126 | launch_tor, 127 | tor_socks_port, 128 | tor_control_port, 129 | local_mode) 130 | else: 131 | self.tab_new = PeerCreationTab(parent=self.notebook, gui=self) 132 | self.notebook.add(self.tab_new, text='Start Peer', sticky=Tk.NS) 133 | 134 | @inlineCallbacks 135 | def init_peer(self, name, 136 | local_server_ip=None, 137 | local_server_port=None, 138 | launch_tor=True, 139 | tor_socks_port=None, 140 | tor_control_port=None, 141 | local_mode=False): 142 | self.notebook.add(self.bootstrap_tab, text='Bootstrap') 143 | 144 | self.peer = Peer.from_disk(name, 145 | self.reactor, 146 | ui=self, 147 | begin_log=True, 148 | begin_log_std=True, 149 | log_level=LogLevel.debug) 150 | try: 151 | notification = yield self.peer.start(local_server_ip, 152 | local_server_port, 153 | launch_tor, 154 | tor_socks_port, 155 | tor_control_port, 156 | local_mode) 157 | except Exception as e: 158 | self.notify_peer_failed( 159 | errors.UnmessageError(title=str(type(e)), message=str(e))) 160 | else: 161 | self.reactor.addSystemEventTrigger('before', 162 | 'shutdown', 163 | self.before_stop) 164 | self.notify_peer_started(notification) 165 | 166 | @threadsafe 167 | def display_info(self, message, title=None): 168 | showinfo(title or 'Information', message) 169 | 170 | @threadsafe 171 | def display_error(self, message, title=None): 172 | showerror(title or 'Error', message) 173 | 174 | @threadsafe 175 | def notify_error(self, error): 176 | self.display_error(error.message, error.title) 177 | 178 | @threadsafe 179 | def notify_bootstrap(self, notification): 180 | self.bootstrap_tab.write_on_text(notification.message) 181 | 182 | @threadsafe 183 | def notify_peer_started(self, notification): 184 | self.bootstrap_tab.destroy() 185 | 186 | self.title(' '.join([self.peer.name, 187 | 'is online at', 188 | self.peer.address.host, 189 | '-', 190 | APP_NAME])) 191 | 192 | # load existing conversations 193 | for c in self.peer.conversations: 194 | self.add_conversation(c) 195 | 196 | # enable the 'New Chat' menu button 197 | self.menu_bar.entryconfig(1, state=Tk.NORMAL) 198 | 199 | # enable the 'Copy Identity' menu button 200 | self.menu_bar.entryconfig(2, state=Tk.NORMAL) 201 | 202 | # enable the 'Copy Key' menu button 203 | self.menu_bar.entryconfig(3, state=Tk.NORMAL) 204 | 205 | # enable the 'Copy Peer' menu button 206 | self.menu_bar.entryconfig(4, state=Tk.NORMAL) 207 | 208 | # enable the 'Copy Onion' menu button 209 | self.menu_bar.entryconfig(5, state=Tk.NORMAL) 210 | 211 | @threadsafe 212 | def notify_peer_failed(self, notification): 213 | self.display_error(notification.message, notification.title) 214 | 215 | @displays_error 216 | @displays_result 217 | def send_request(self, identity, key): 218 | return self.peer.send_request(identity, key) 219 | 220 | @displays_error 221 | @displays_result 222 | @inlineCallbacks 223 | def accept_request(self, identity, new_name): 224 | notification = yield self.peer.accept_request(identity, new_name) 225 | self.add_conversation(notification.conversation) 226 | returnValue(notification) 227 | 228 | @displays_result 229 | def notify_conv_established(self, notification): 230 | self.add_conversation(notification.conversation) 231 | return notification 232 | 233 | @threadsafe 234 | def add_conversation(self, conversation): 235 | new_tab = ChatTab(parent=self.notebook, 236 | gui=self, 237 | peer=self.peer, 238 | conversation=conversation) 239 | new_tab.text_message.mark_set(Tk.INSERT, 1.0) 240 | new_tab.text_message.focus_set() 241 | conversation.ui = new_tab 242 | self.notebook.add(new_tab, text=conversation.contact.name) 243 | 244 | @threadsafe 245 | def create_request(self): 246 | w = OutboundRequestWindow(gui=self, 247 | peer=self.peer) 248 | self.wait_window(w) 249 | 250 | @threadsafe 251 | def notify_in_request(self, notification): 252 | w = InboundRequestWindow(gui=self, 253 | peer=self.peer, 254 | contact=notification.contact) 255 | self.wait_window(w) 256 | 257 | def copy_identity(self): 258 | self.peer.copy_identity() 259 | 260 | def copy_key(self): 261 | self.peer.copy_key() 262 | 263 | def copy_peer(self): 264 | self.peer.copy_peer() 265 | 266 | def copy_onion(self): 267 | self.peer.copy_onion() 268 | 269 | @inlineCallbacks 270 | def before_stop(self): 271 | if self.peer is None: 272 | # the user never initialized a peer 273 | pass 274 | else: 275 | yield self.peer.stop() 276 | 277 | def stop(self): 278 | self.reactor.stop() 279 | 280 | 281 | class BootstrapTab(Tk.Frame, object): 282 | def __init__(self, parent, gui): 283 | super(BootstrapTab, self).__init__(parent) 284 | 285 | self.gui = gui 286 | 287 | frame_tab = Tk.Frame(self) 288 | frame_tab.grid(padx=15, pady=15) 289 | 290 | frame_text = Tk.LabelFrame(frame_tab) 291 | frame_text.grid() 292 | 293 | self.text = Tk.Text(frame_text, 294 | height=34, 295 | state=Tk.DISABLED, 296 | wrap=Tk.WORD) 297 | self.text.grid(row=0, column=0) 298 | scrollbar_body = Tk.Scrollbar(frame_text, 299 | command=self.text.yview) 300 | scrollbar_body.grid(row=0, column=1, sticky=Tk.NSEW) 301 | self.text.config(yscrollcommand=scrollbar_body.set) 302 | 303 | def write_on_text(self, content): 304 | write_on_text(self.text, 305 | content=[content + '\n'], 306 | clear=False) 307 | 308 | 309 | def bind_checkbutton(checkbutton, method): 310 | def handler(event): 311 | new_state = not checkbutton.var.get() 312 | method(new_state) 313 | events = ['', '', ''] 314 | bind_handler_to_widget_events(handler, checkbutton, events) 315 | 316 | 317 | def bind_handler_to_widget_events(handler, widget, events): 318 | for event in events: 319 | widget.bind(event, handler) 320 | 321 | 322 | class ChatTab(Tk.Frame, ConversationUi, object): 323 | def __init__(self, parent, gui, peer, conversation): 324 | super(ChatTab, self).__init__(parent) 325 | 326 | self.conversation = conversation 327 | self.gui = gui 328 | self.peer = peer 329 | self.calls_queue = gui.calls_queue 330 | 331 | frame_tab = Tk.Frame(self) 332 | frame_tab.grid(padx=15, pady=15) 333 | 334 | self.frame_conversation = Tk.LabelFrame(frame_tab, text='Conversation') 335 | self.frame_conversation.grid() 336 | 337 | self.update_frame() 338 | 339 | self.text_conversation = Tk.Text(self.frame_conversation, 340 | height=22, 341 | state=Tk.DISABLED, 342 | wrap=Tk.WORD) 343 | self.text_conversation.grid(row=0, column=0) 344 | scrollbar_body = Tk.Scrollbar(self.frame_conversation, 345 | command=self.text_conversation.yview) 346 | scrollbar_body.grid(row=0, column=1, sticky=Tk.NSEW) 347 | self.text_conversation.config(yscrollcommand=scrollbar_body.set) 348 | 349 | frame_input = Tk.Frame(frame_tab) 350 | frame_input.grid(pady=(10, 0)) 351 | 352 | frame_message = Tk.LabelFrame(frame_input, text='Message') 353 | frame_message.grid(row=0, column=0) 354 | self.text_message = Tk.Text(frame_message, 355 | width=71, height=4, wrap=Tk.WORD) 356 | self.text_message.grid(row=0, column=0) 357 | scrollbar_message = Tk.Scrollbar(frame_message, 358 | command=self.text_message.yview) 359 | scrollbar_message.grid(row=0, column=1, sticky=Tk.NSEW) 360 | self.text_message.config(yscrollcommand=scrollbar_message.set) 361 | 362 | button_send = Tk.Button( 363 | frame_input, text='Send', 364 | command=lambda: self.send_message( 365 | self.text_message.get(1.0, Tk.END).strip())) 366 | button_send.grid(row=0, column=1, pady=(6, 0), sticky=Tk.NSEW) 367 | 368 | var_presence = Tk.BooleanVar( 369 | value=conversation and conversation.contact.has_presence) 370 | self.check_presence = Tk.Checkbutton(frame_tab, 371 | text='Send Presence', 372 | variable=var_presence) 373 | self.check_presence.var = var_presence 374 | self.check_presence.grid(pady=(10, 0), sticky=Tk.W) 375 | bind_checkbutton(self.check_presence, self.set_presence) 376 | 377 | buttons_row = frame_tab.grid_size()[1] + 1 378 | 379 | button_delete = Tk.Button(frame_tab, 380 | text='Delete', 381 | command=self.delete) 382 | button_delete.grid(row=buttons_row, pady=(10, 0), sticky=Tk.W) 383 | 384 | button_verify = Tk.Button(frame_tab, 385 | text='Verify', 386 | command=self.verify) 387 | button_verify.grid(row=buttons_row, pady=(10, 0)) 388 | 389 | button_authenticate = Tk.Button(frame_tab, 390 | text='Authenticate', 391 | command=self.authenticate) 392 | button_authenticate.grid(row=buttons_row, pady=(10, 0), sticky=Tk.E) 393 | 394 | self.text_message.bind('', 395 | lambda event: self.send_with_return( 396 | self.text_message.get(1.0, Tk.END).strip())) 397 | 398 | def update_frame(self): 399 | if self.conversation: 400 | text, color = get_auth_frame_configs(self.conversation) 401 | self.frame_conversation.config( 402 | text='{} Conversation'.format(text), foreground=color) 403 | 404 | def send_with_return(self, message): 405 | self.send_message(message) 406 | 407 | # prevent propagation of the event to other handlers so that a line 408 | # break is not added to the ``Text`` 409 | return "break" 410 | 411 | def write_on_text(self, content): 412 | write_on_text(text=self.text_conversation, 413 | content=[content + '\n'], 414 | clear=False) 415 | 416 | def display_info(self, message, title=None): 417 | self.gui.display_info(message, title) 418 | 419 | def display_error(self, message, title=None): 420 | self.gui.display_error(message, title) 421 | 422 | @threadsafe 423 | def display_message(self, message, sender): 424 | self.write_on_text(content='{}: {}'.format(sender, message)) 425 | 426 | # scroll to the bottom 427 | self.text_conversation.yview('moveto', 1.0) 428 | 429 | @displays_error 430 | @inlineCallbacks 431 | def send_message(self, message): 432 | if len(message): 433 | self.text_message.delete(1.0, Tk.END) 434 | yield self.conversation.send_message(message) 435 | self.display_message(message, self.peer.name) 436 | 437 | def set_presence(self, enable): 438 | self.peer.set_presence(self.conversation.contact.name, enable) 439 | 440 | def delete(self): 441 | if askyesno(title='Deletion', 442 | message='Are you sure you wish to delete this ' 443 | 'conversation?'): 444 | self.peer.delete_conversation(self.conversation.contact.name) 445 | self.destroy() 446 | 447 | def verify(self): 448 | key = askstring(title='Verification', 449 | prompt="Provide the contact's public key:", 450 | parent=self) 451 | if key: 452 | try: 453 | self.peer.verify_contact(self.conversation.contact.name, key) 454 | except errors.VerificationError as e: 455 | self.display_error(e.message, e.title) 456 | else: 457 | self.display_info(title='Verification', 458 | message="{}'s key has been verified.".format( 459 | self.conversation.contact.name)) 460 | self.update_frame() 461 | 462 | @displays_error 463 | @displays_result 464 | def authenticate(self): 465 | secret = askstring(title='Authentication', 466 | prompt='Provide the shared secret:', 467 | parent=self, 468 | show='*') 469 | if secret: 470 | return self.conversation.authenticate(secret) 471 | 472 | @threadsafe 473 | def notify_disconnect(self, notification): 474 | self.notify_status_change(notification) 475 | 476 | @threadsafe 477 | def notify_offline(self, notification): 478 | self.notify_status_change(notification) 479 | 480 | @threadsafe 481 | def notify_online(self, notification): 482 | self.notify_status_change(notification) 483 | 484 | @threadsafe 485 | def notify_status_change(self, notification): 486 | self.update_frame() 487 | self.write_on_text(notification.message) 488 | 489 | def notify_message(self, notification): 490 | self.display_message(notification.message, notification.element.sender) 491 | 492 | @threadsafe 493 | def notify_finished_authentication(self, notification): 494 | self.write_on_text(notification.message) 495 | self.update_frame() 496 | 497 | @threadsafe 498 | def notify_in_authentication(self, notification): 499 | self.write_on_text('{} - click "Authenticate" to proceed'.format( 500 | notification.message)) 501 | 502 | @threadsafe 503 | def notify_out_authentication(self, notification): 504 | self.write_on_text(notification.message) 505 | 506 | 507 | class PeerCreationTab(Tk.Frame, object): 508 | def __init__(self, parent, gui): 509 | super(PeerCreationTab, self).__init__(parent) 510 | 511 | self.gui = gui 512 | 513 | frame_tab = Tk.Frame(self) 514 | frame_tab.grid(padx=15, pady=15) 515 | 516 | label_info = Tk.Label(frame_tab, text='How will peers find you?') 517 | label_info.pack() 518 | 519 | label_name = Tk.Label(frame_tab, text='Name') 520 | label_name.pack(anchor=Tk.W) 521 | entry_name = Tk.Entry(frame_tab) 522 | entry_name.pack() 523 | 524 | label_local_server_port = Tk.Label(frame_tab, 525 | text='Local Server Port (Optional)') 526 | label_local_server_port.pack(anchor=Tk.W) 527 | entry_local_server_port = Tk.Entry(frame_tab) 528 | entry_local_server_port.pack() 529 | 530 | label_tor_socks_port = Tk.Label(frame_tab, 531 | text='Tor Port (Optional)') 532 | label_tor_socks_port.pack(anchor=Tk.W) 533 | entry_tor_socks_port = Tk.Entry(frame_tab) 534 | entry_tor_socks_port.pack() 535 | 536 | label_tor_control_port = Tk.Label( 537 | frame_tab, 538 | text='Tor Control Port (Optional)') 539 | label_tor_control_port.pack(anchor=Tk.W) 540 | entry_tor_control_port = Tk.Entry(frame_tab) 541 | entry_tor_control_port.pack() 542 | 543 | button_start = Tk.Button( 544 | frame_tab, text='Start', 545 | command=lambda: self.init_peer( 546 | entry_name.get().strip(), 547 | entry_local_server_port.get().strip(), 548 | entry_tor_socks_port.get().strip(), 549 | entry_tor_control_port.get().strip())) 550 | button_start.pack(pady=(10, 0)) 551 | 552 | entry_name.focus_set() 553 | 554 | self.bind_class('Entry', 555 | '', 556 | lambda event: self.init_peer( 557 | entry_name.get().strip(), 558 | entry_local_server_port.get().strip(), 559 | entry_tor_socks_port.get().strip(), 560 | entry_tor_control_port.get().strip())) 561 | 562 | def init_peer(self, name, local_server_port, 563 | tor_socks_port, tor_control_port): 564 | try: 565 | self.gui.init_peer(name, 566 | local_server_port=local_server_port, 567 | tor_socks_port=tor_socks_port, 568 | tor_control_port=tor_control_port) 569 | except errors.InvalidNameError as e: 570 | self.gui.display_error(e.message, e.title) 571 | else: 572 | self.destroy() 573 | 574 | 575 | class RequestWindow(Tk.Toplevel, object): 576 | def __init__(self, gui, peer, contact=None): 577 | super(RequestWindow, self).__init__(gui) 578 | 579 | self.title('New Chat') 580 | self.gui = gui 581 | self.peer = peer 582 | self.contact = contact 583 | 584 | frame = Tk.Frame(self) 585 | frame.pack(padx=15, pady=15) 586 | 587 | if contact: 588 | state = Tk.DISABLED 589 | identity = contact.identity 590 | key = b2a(contact.key) 591 | info_text = 'Chat request received!\nDo you know this peer?' 592 | button_text = 'Accept Request' 593 | else: 594 | state = Tk.NORMAL 595 | identity = '' 596 | key = '' 597 | info_text = 'Whom would you like\nto chat with?' 598 | button_text = 'Send Request' 599 | 600 | label_info = Tk.Label(frame, text=info_text) 601 | label_info.pack() 602 | 603 | label_identity = Tk.Label(frame, text='Identity Address') 604 | label_identity.pack(anchor=Tk.W) 605 | self.entry_identity = Tk.Entry(frame, state=state, width=44) 606 | write_on_text(self.entry_identity, content=[identity], clear=False) 607 | self.entry_identity.pack() 608 | 609 | label_key = Tk.Label(frame, text='Identity Key') 610 | label_key.pack(anchor=Tk.W) 611 | self.entry_key = Tk.Entry(frame, state=state, width=44) 612 | write_on_text(self.entry_key, content=[key], clear=False) 613 | self.entry_key.pack() 614 | 615 | if contact: 616 | label_name = Tk.Label(frame, text='New name (Optional)') 617 | label_name.pack(anchor=Tk.W) 618 | self.entry_name = Tk.Entry(frame, width=44) 619 | self.entry_name.pack() 620 | 621 | button_create = Tk.Button(frame, text=button_text, 622 | command=self.send_or_accept) 623 | button_create.pack(pady=(10, 0)) 624 | 625 | self.bind('', 626 | lambda event: self.send_or_accept()) 627 | 628 | if contact: 629 | self.entry_name.focus_set() 630 | else: 631 | self.entry_identity.focus_set() 632 | 633 | def send_or_accept(self): 634 | pass 635 | 636 | 637 | class InboundRequestWindow(RequestWindow): 638 | def __init__(self, gui, peer, contact): 639 | super(InboundRequestWindow, self).__init__(gui, peer, contact) 640 | 641 | def send_or_accept(self): 642 | self.gui.accept_request(identity=self.contact.identity, 643 | new_name=self.entry_name.get().strip()) 644 | self.destroy() 645 | 646 | 647 | class OutboundRequestWindow(RequestWindow): 648 | def __init__(self, gui, peer): 649 | super(OutboundRequestWindow, self).__init__(gui, peer) 650 | 651 | def send_or_accept(self): 652 | self.gui.send_request(identity=self.entry_identity.get().strip(), 653 | key=self.entry_key.get().strip()) 654 | self.destroy() 655 | 656 | 657 | COLOR_PURPLE = 'purple' 658 | COLOR_GREEN = 'green' 659 | COLOR_RED = 'red' 660 | 661 | 662 | def get_auth_frame_configs(conversation): 663 | if conversation.is_authenticated: 664 | return 'Authenticated', COLOR_PURPLE 665 | elif conversation.contact.is_verified: 666 | return 'Verified', COLOR_GREEN 667 | else: 668 | return 'Unverified', COLOR_RED 669 | -------------------------------------------------------------------------------- /unmessage/log.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | 4 | from twisted.logger import globalLogBeginner, Logger 5 | from twisted.logger import _loggerFor as loggerFor 6 | from twisted.logger import FilteringLogObserver, ILogFilterPredicate 7 | from twisted.logger import LogLevelFilterPredicate, LogLevel, PredicateResult 8 | from twisted.logger import STDLibLogObserver, textFileLogObserver 9 | from twisted.python.compat import _PY3 10 | from zope.interface import provider 11 | 12 | 13 | LOG_LEVEL = LogLevel.warn 14 | 15 | NAMESPACES = [ 16 | 'pytest', 17 | 'unmessage.__main__', 18 | 'unmessage.cli.Cli', 19 | 'unmessage.gui.Gui', 20 | 'unmessage.peer', 21 | 'unmessage.peer._ConversationFactory', 22 | 'unmessage.peer._ConversationProtocol', 23 | 'unmessage.peer.Conversation', 24 | 'unmessage.peer.Introduction', 25 | 'unmessage.peer.Peer', 26 | ] 27 | 28 | 29 | @provider(ILogFilterPredicate) 30 | def filter_unmessage_event(event): 31 | if (event['log_level'] >= LogLevel.warn or 32 | event['log_namespace'] in NAMESPACES): 33 | return PredicateResult.yes 34 | else: 35 | return PredicateResult.no 36 | 37 | 38 | def get_filtering_observer(observer, log_level): 39 | return FilteringLogObserver(observer, 40 | predicates=[LogLevelFilterPredicate(log_level), 41 | filter_unmessage_event]) 42 | 43 | 44 | # Mappings to Python's logging module 45 | toStdlibLogLevelMapping = { 46 | LogLevel.debug: logging.DEBUG, 47 | LogLevel.info: logging.INFO, 48 | LogLevel.warn: logging.WARNING, 49 | LogLevel.error: logging.ERROR, 50 | LogLevel.critical: logging.CRITICAL, 51 | } 52 | 53 | 54 | class StdLibLogObserver(STDLibLogObserver): 55 | def __init__(self, name, formatEvent): 56 | super(StdLibLogObserver, self).__init__(name) 57 | 58 | if _PY3: 59 | self.formatEvent = formatEvent 60 | else: 61 | self.formatEvent = lambda event: formatEvent(event).encode('utf-8') 62 | 63 | logging.basicConfig(level=logging.DEBUG, format='%(message)s') 64 | 65 | def __call__(self, event): 66 | text = self.formatEvent(event).strip() 67 | 68 | if text: 69 | level = event.get('log_level', LogLevel.info) 70 | stdlibLevel = toStdlibLogLevelMapping.get(level, logging.INFO) 71 | 72 | failure = event.get('log_failure') 73 | if failure is None: 74 | excInfo = None 75 | else: 76 | excInfo = (failure.type, 77 | failure.value, 78 | failure.getTracebackObject()) 79 | 80 | self.logger.log(stdlibLevel, text, exc_info=excInfo) 81 | 82 | 83 | def get_std_observer(name, format_event): 84 | return StdLibLogObserver(name, format_event) 85 | 86 | 87 | def get_file_observer(filepath): 88 | return textFileLogObserver(io.open(filepath, 'a')) 89 | 90 | 91 | def begin_logging_to_std(name, log_level=LOG_LEVEL): 92 | logging.basicConfig(level=logging.DEBUG) 93 | observer = get_filtering_observer(get_std_observer(name), log_level) 94 | 95 | globalLogBeginner.beginLoggingTo([observer]) 96 | 97 | 98 | def begin_logging_to_file(filepath, log_level=LOG_LEVEL): 99 | observer = get_filtering_observer(get_file_observer(filepath), log_level) 100 | 101 | globalLogBeginner.beginLoggingTo([observer]) 102 | 103 | 104 | def begin_logging(filepath, log_level=LOG_LEVEL, begin_std=False): 105 | file_observer = get_file_observer(filepath) 106 | observers = [get_filtering_observer(file_observer, log_level)] 107 | 108 | if begin_std: 109 | stdlib_observer = get_std_observer('unmessage', 110 | file_observer.formatEvent) 111 | observers.append(get_filtering_observer(stdlib_observer, log_level)) 112 | 113 | globalLogBeginner.beginLoggingTo(observers) 114 | -------------------------------------------------------------------------------- /unmessage/notifications.py: -------------------------------------------------------------------------------- 1 | class UnmessageNotification(object): 2 | def __init__(self, message, title=None): 3 | self.title = title or 'unMessage' 4 | self.message = message 5 | 6 | def __str__(self): 7 | return self.message 8 | 9 | 10 | class UntalkNotification(UnmessageNotification): 11 | def __init__(self, message, title=None): 12 | super(UntalkNotification, self).__init__( 13 | message, 14 | title or 'unTalk') 15 | 16 | 17 | class ContactNotification(UnmessageNotification): 18 | def __init__(self, contact, message, title=None): 19 | super(ContactNotification, self).__init__( 20 | message, 21 | title) 22 | self.contact = contact 23 | 24 | 25 | class ConversationNotification(UnmessageNotification): 26 | def __init__(self, conversation, message, title=None): 27 | super(ConversationNotification, self).__init__( 28 | message, 29 | title) 30 | self.conversation = conversation 31 | 32 | 33 | class ElementNotification(UnmessageNotification): 34 | def __init__(self, element, message=None, title=None): 35 | super(ElementNotification, self).__init__( 36 | message or str(element), 37 | title) 38 | self.element = element 39 | 40 | 41 | class FileNotification(UnmessageNotification): 42 | def __init__(self, message, transfer, title=None): 43 | super(FileNotification, self).__init__( 44 | message, 45 | title) 46 | self.transfer = transfer 47 | -------------------------------------------------------------------------------- /unmessage/packets.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | import attr 4 | from pyaxo import a2b 5 | 6 | from . import errors 7 | from .utils import raise_if_not 8 | 9 | 10 | IV_LEN = 8 11 | KEY_LEN = 32 12 | ENC_KEY_LEN = 72 13 | HASH_LEN = 32 14 | 15 | LINESEP = '\n' 16 | 17 | 18 | def raise_malformed(f): 19 | @wraps(f) 20 | def try_building(cls, data): 21 | try: 22 | return f(cls, data) 23 | except (AssertionError, IndexError, TypeError, ValueError): 24 | e = errors.MalformedPacketError(cls.__name__) 25 | indexed_lines = ['[{}]: {}'.format(index, line) 26 | for index, line in enumerate(data.splitlines())] 27 | e.message = LINESEP.join([e.message] + indexed_lines) 28 | raise e 29 | return try_building 30 | 31 | 32 | def is_valid_length(value, length): 33 | try: 34 | return isinstance(value, str) and len(a2b(value)) == length 35 | except TypeError: 36 | return False 37 | 38 | 39 | def is_valid_non_empty(value): 40 | try: 41 | return isinstance(value, str) and len(a2b(value)) > 0 42 | except TypeError: 43 | return False 44 | 45 | 46 | def is_valid_iv(value): 47 | return is_valid_length(value, IV_LEN) 48 | 49 | 50 | def is_valid_key(value): 51 | return is_valid_length(value, KEY_LEN) 52 | 53 | 54 | def is_valid_enc_key(value): 55 | return is_valid_length(value, ENC_KEY_LEN) 56 | 57 | 58 | def is_valid_hash(value): 59 | return is_valid_length(value, HASH_LEN) 60 | 61 | 62 | def is_valid_empty(value): 63 | return is_valid_length(value, 0) 64 | 65 | 66 | @attr.s 67 | class Packet(object): 68 | @classmethod 69 | @raise_malformed 70 | def build(cls, data): 71 | return cls(*data.splitlines()) 72 | 73 | def __str__(self): 74 | return LINESEP.join([str(getattr(self, a.name)) 75 | for a in attr.fields(type(self))]) 76 | 77 | 78 | @attr.s 79 | class IdentifiablePacket(Packet): 80 | iv = attr.ib(validator=raise_if_not(is_valid_iv)) 81 | iv_hash = attr.ib(validator=raise_if_not(is_valid_hash)) 82 | 83 | 84 | @attr.s 85 | class IntroductionPacket(IdentifiablePacket): 86 | tail = attr.ib(validator=raise_if_not(is_valid_non_empty)) 87 | 88 | @classmethod 89 | @raise_malformed 90 | def build(cls, data): 91 | lines = data.splitlines() 92 | return cls(iv=lines[0], 93 | iv_hash=lines[1], 94 | tail=LINESEP.join(lines[2:])) 95 | 96 | 97 | @attr.s 98 | class RegularPacket(IdentifiablePacket): 99 | payload_hash = attr.ib(validator=raise_if_not(is_valid_hash)) 100 | handshake_key = attr.ib(validator=raise_if_not(is_valid_empty)) 101 | payload = attr.ib(validator=raise_if_not(is_valid_non_empty)) 102 | 103 | 104 | @attr.s 105 | class ReplyPacket(IdentifiablePacket): 106 | payload_hash = attr.ib(validator=raise_if_not(is_valid_hash)) 107 | handshake_key = attr.ib(validator=raise_if_not(is_valid_enc_key)) 108 | payload = attr.ib(validator=raise_if_not(is_valid_non_empty)) 109 | 110 | 111 | @attr.s 112 | class RequestPacket(IdentifiablePacket): 113 | handshake_packet_hash = attr.ib(validator=raise_if_not(is_valid_hash)) 114 | request_key = attr.ib(validator=raise_if_not(is_valid_key)) 115 | handshake_packet = attr.ib(raise_if_not(is_valid_non_empty)) 116 | 117 | 118 | @attr.s 119 | class HandshakePacket(Packet): 120 | identity = attr.ib(validator=attr.validators.instance_of(str)) 121 | identity_key = attr.ib(validator=raise_if_not(is_valid_key)) 122 | handshake_key = attr.ib(validator=raise_if_not(is_valid_key)) 123 | ratchet_key = attr.ib(validator=raise_if_not(is_valid_key)) 124 | 125 | 126 | @attr.s 127 | class ElementPacket(Packet): 128 | type_ = attr.ib(validator=attr.validators.instance_of(str)) 129 | id_ = attr.ib(validator=attr.validators.instance_of(str)) 130 | part_num = attr.ib(convert=int) 131 | part_total = attr.ib(convert=int) 132 | payload = attr.ib(validator=attr.validators.instance_of(str)) 133 | 134 | @classmethod 135 | @raise_malformed 136 | def build(cls, data): 137 | lines = data.splitlines() 138 | return cls(type_=lines[0], 139 | id_=lines[1], 140 | part_num=lines[2], 141 | part_total=lines[3], 142 | payload=LINESEP.join(lines[4:])) 143 | -------------------------------------------------------------------------------- /unmessage/persistence.py: -------------------------------------------------------------------------------- 1 | import errno 2 | import sqlite3 3 | 4 | import attr 5 | from pyaxo import Keypair, a2b, b2a 6 | 7 | from .contact import Contact 8 | 9 | 10 | @attr.s 11 | class PeerInfo(object): 12 | name = attr.ib( 13 | validator=attr.validators.optional(attr.validators.instance_of(str)), 14 | default=None) 15 | port_local_server = attr.ib( 16 | validator=attr.validators.optional(attr.validators.instance_of(int)), 17 | default=None) 18 | identity_keys = attr.ib( 19 | validator=attr.validators.optional( 20 | attr.validators.instance_of(Keypair)), 21 | default=None) 22 | onion_service_key = attr.ib( 23 | validator=attr.validators.optional(attr.validators.instance_of(str)), 24 | default=None) 25 | contacts = attr.ib( 26 | validator=attr.validators.optional(attr.validators.instance_of(dict)), 27 | default=attr.Factory(dict)) 28 | 29 | 30 | @attr.s 31 | class Persistence(object): 32 | dbname = attr.ib(validator=attr.validators.instance_of(str)) 33 | dbpassphrase = attr.ib( 34 | validator=attr.validators.optional(attr.validators.instance_of(dict)), 35 | default=None) 36 | db = attr.ib(init=False) 37 | 38 | def __attrs_post_init__(self): 39 | self.db = self._open_db() 40 | 41 | @classmethod 42 | def create(cls, paths): 43 | return cls(paths.peer_db) 44 | 45 | def _open_db(self): 46 | db = sqlite3.connect(':memory:', check_same_thread=False) 47 | db.row_factory = sqlite3.Row 48 | 49 | with db: 50 | try: 51 | with open(self.dbname, 'r') as f: 52 | sql = f.read() 53 | db.cursor().executescript(sql) 54 | except IOError as e: 55 | if e.errno == errno.ENOENT: 56 | self._create_db(db) 57 | else: 58 | raise 59 | return db 60 | 61 | def _create_db(self, db): 62 | db.execute(''' 63 | CREATE TABLE IF NOT EXISTS 64 | peer ( 65 | name TEXT, 66 | port_local_server INTEGER, 67 | priv_identity_key TEXT, 68 | pub_identity_key TEXT, 69 | onion_service_key TEXT)''') 70 | db.execute(''' 71 | CREATE UNIQUE INDEX IF NOT EXISTS 72 | peer_name 73 | ON 74 | peer (name)''') 75 | db.execute(''' 76 | CREATE TABLE IF NOT EXISTS 77 | contacts ( 78 | identity TEXT, 79 | key TEXT, 80 | is_verified INTEGER, 81 | has_presence INTEGER)''') 82 | db.execute(''' 83 | CREATE UNIQUE INDEX IF NOT EXISTS 84 | contact_identity 85 | ON 86 | contacts (identity)''') 87 | 88 | def _write_db(self): 89 | with self.db as db: 90 | sql = bytes('\n'.join(db.iterdump())) 91 | with open(self.dbname, 'w') as f: 92 | f.write(sql) 93 | 94 | def load_peer_info(self): 95 | with self.db as db: 96 | cur = db.cursor() 97 | cur.execute(''' 98 | SELECT 99 | * 100 | FROM 101 | peer''') 102 | row = cur.fetchone() 103 | if row: 104 | onion_service_key = str(row['onion_service_key']) 105 | identity_keys = Keypair(a2b(row['priv_identity_key']), 106 | a2b(row['pub_identity_key'])) 107 | port_local_server = int(row['port_local_server']) 108 | name = str(row['name']) 109 | else: 110 | onion_service_key = None 111 | identity_keys = None 112 | port_local_server = None 113 | name = None 114 | 115 | with self.db as db: 116 | rows = db.execute(''' 117 | SELECT 118 | * 119 | FROM 120 | contacts''') 121 | contacts = dict() 122 | for row in rows: 123 | c = Contact(str(row['identity']), 124 | a2b(row['key']), 125 | bool(row['is_verified']), 126 | bool(row['has_presence'])) 127 | contacts[c.name] = c 128 | 129 | return PeerInfo(name, port_local_server, identity_keys, 130 | onion_service_key, contacts) 131 | 132 | def save_peer_info(self, peer_info): 133 | with self.db as db: 134 | db.execute(''' 135 | DELETE FROM 136 | peer''') 137 | if peer_info.identity_keys: 138 | db.execute(''' 139 | INSERT INTO 140 | peer ( 141 | name, 142 | port_local_server, 143 | priv_identity_key, 144 | pub_identity_key, 145 | onion_service_key) 146 | VALUES (?, ?, ?, ?, ?)''', ( 147 | peer_info.name, 148 | peer_info.port_local_server, 149 | b2a(peer_info.identity_keys.priv), 150 | b2a(peer_info.identity_keys.pub), 151 | peer_info.onion_service_key)) 152 | db.execute(''' 153 | DELETE FROM 154 | contacts''') 155 | for c in peer_info.contacts.values(): 156 | db.execute(''' 157 | INSERT INTO 158 | contacts ( 159 | identity, 160 | key, 161 | is_verified, 162 | has_presence) 163 | VALUES (?, ?, ?, ?)''', ( 164 | c.identity, 165 | b2a(c.key), 166 | int(c.is_verified), 167 | int(c.has_presence))) 168 | 169 | self._write_db() 170 | -------------------------------------------------------------------------------- /unmessage/smp.py: -------------------------------------------------------------------------------- 1 | """ 2 | Code is based on: 3 | https://github.com/shanet/Cryptully/blob/master/src/crypto/smp.py 4 | originally written by Shane Tully and released under the LGPL. 5 | """ 6 | import hashlib 7 | import random 8 | import struct 9 | 10 | class SMP(object): 11 | def __init__(self, secret=None): 12 | # 4096-bit safe prime (RFC 3526) 13 | self.mod = 0xFFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A08798E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF6955817183995497CEA956AE515D2261898FA051015728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6BF12FFA06D98A0864D87602733EC86A64521F2B18177B200CBBE117577A615D6C770988C0BAD946E208E24FA074E5AB3143DB5BFCE0FD108E4B82D120A92108011A723C12A787E6D788719A10BDBA5B2699C327186AF4E23C1A946834B6150BDA2583E9CA2AD44CE8DBBBC2DB04DE8EF92E8EFC141FBECAA6287C59474E6BC05D99B2964FA090C3A2233BA186515BE7ED1F612970CEE2D7AFB81BDD762170481CD0069127D5B05AA993B4EA988D8FDDC186FFB7DC90A6C08F4DF435C934063199FFFFFFFFFFFFFFFF 14 | self.modOrder = (self.mod-1) / 2 15 | self.gen = 2 16 | self.match = False 17 | 18 | self.secret = sha256(secret) 19 | 20 | def step1(self): 21 | self.x2 = createRandomExponent() 22 | self.x3 = createRandomExponent() 23 | 24 | self.g2 = pow(self.gen, self.x2, self.mod) 25 | self.g3 = pow(self.gen, self.x3, self.mod) 26 | 27 | (c1, d1) = self.createLogProof('1', self.x2) 28 | (c2, d2) = self.createLogProof('2', self.x3) 29 | 30 | # Send g2a, g3a, c1, d1, c2, d2 31 | return packList(self.g2, self.g3, c1, d1, c2, d2) 32 | 33 | def step2(self, buff): 34 | (g2a, g3a, c1, d1, c2, d2) = unpackList(buff) 35 | 36 | if not self.isValidArgument(g2a) or not self.isValidArgument(g3a): 37 | raise ValueError("Invalid g2a/g3a values") 38 | 39 | if not self.checkLogProof('1', g2a, c1, d1): 40 | raise ValueError("Proof 1 check failed") 41 | 42 | if not self.checkLogProof('2', g3a, c2, d2): 43 | raise ValueError("Proof 2 check failed") 44 | 45 | self.g2a = g2a 46 | self.g3a = g3a 47 | 48 | self.x2 = createRandomExponent() 49 | self.x3 = createRandomExponent() 50 | 51 | r = createRandomExponent() 52 | 53 | self.g2 = pow(self.gen, self.x2, self.mod) 54 | self.g3 = pow(self.gen, self.x3, self.mod) 55 | 56 | (c3, d3) = self.createLogProof('3', self.x2) 57 | (c4, d4) = self.createLogProof('4', self.x3) 58 | 59 | self.gb2 = pow(self.g2a, self.x2, self.mod) 60 | self.gb3 = pow(self.g3a, self.x3, self.mod) 61 | 62 | self.pb = pow(self.gb3, r, self.mod) 63 | self.qb = mulm(pow(self.gen, r, self.mod), pow(self.gb2, self.secret, self.mod), self.mod) 64 | 65 | (c5, d5, d6) = self.createCoordsProof('5', self.gb2, self.gb3, r) 66 | 67 | # Sends g2b, g3b, pb, qb, all the c's and d's 68 | return packList(self.g2, self.g3, self.pb, self.qb, c3, d3, c4, d4, c5, d5, d6) 69 | 70 | def step3(self, buff): 71 | (g2b, g3b, pb, qb, c3, d3, c4, d4, c5, d5, d6) = unpackList(buff) 72 | 73 | if not self.isValidArgument(g2b) or not self.isValidArgument(g3b) or \ 74 | not self.isValidArgument(pb) or not self.isValidArgument(qb): 75 | raise ValueError("Invalid g2b/g3b/pb/qb values") 76 | 77 | if not self.checkLogProof('3', g2b, c3, d3): 78 | raise ValueError("Proof 3 check failed") 79 | 80 | if not self.checkLogProof('4', g3b, c4, d4): 81 | raise ValueError("Proof 4 check failed") 82 | 83 | self.g2b = g2b 84 | self.g3b = g3b 85 | 86 | self.ga2 = pow(self.g2b, self.x2, self.mod) 87 | self.ga3 = pow(self.g3b, self.x3, self.mod) 88 | 89 | if not self.checkCoordsProof('5', c5, d5, d6, self.ga2, self.ga3, pb, qb): 90 | raise ValueError("Proof 5 check failed") 91 | 92 | s = createRandomExponent() 93 | 94 | self.qb = qb 95 | self.pb = pb 96 | self.pa = pow(self.ga3, s, self.mod) 97 | self.qa = mulm(pow(self.gen, s, self.mod), pow(self.ga2, self.secret, self.mod), self.mod) 98 | 99 | (c6, d7, d8) = self.createCoordsProof('6', self.ga2, self.ga3, s) 100 | 101 | inv = self.invm(qb) 102 | self.ra = pow(mulm(self.qa, inv, self.mod), self.x3, self.mod) 103 | 104 | (c7, d9) = self.createEqualLogsProof('7', self.qa, inv, self.x3) 105 | 106 | # Sends pa, qa, ra, c6, d7, d8, c7, d9 107 | return packList(self.pa, self.qa, self.ra, c6, d7, d8, c7, d9) 108 | 109 | def step4(self, buff): 110 | (pa, qa, ra, c6, d7, d8, c7, d9) = unpackList(buff) 111 | 112 | if not self.isValidArgument(pa) or not self.isValidArgument(qa) or not self.isValidArgument(ra): 113 | raise ValueError("Invalid pa/qa/ra values") 114 | 115 | if not self.checkCoordsProof('6', c6, d7, d8, self.gb2, self.gb3, pa, qa): 116 | raise ValueError("Proof 6 check failed") 117 | 118 | if not self.checkEqualLogs('7', c7, d9, self.g3a, mulm(qa, self.invm(self.qb), self.mod), ra): 119 | raise ValueError("Proof 7 check failed") 120 | 121 | inv = self.invm(self.qb) 122 | rb = pow(mulm(qa, inv, self.mod), self.x3, self.mod) 123 | 124 | (c8, d10) = self.createEqualLogsProof('8', qa, inv, self.x3) 125 | 126 | rab = pow(ra, self.x3, self.mod) 127 | 128 | inv = self.invm(self.pb) 129 | if rab == mulm(pa, inv, self.mod): 130 | self.match = True 131 | 132 | # Send rb, c8, d10 133 | return packList(rb, c8, d10) 134 | 135 | def step5(self, buff): 136 | (rb, c8, d10) = unpackList(buff) 137 | 138 | if not self.isValidArgument(rb): 139 | raise ValueError("Invalid rb values") 140 | 141 | if not self.checkEqualLogs('8', c8, d10, self.g3b, mulm(self.qa, self.invm(self.qb), self.mod), rb): 142 | raise ValueError("Proof 8 check failed") 143 | 144 | rab = pow(rb, self.x3, self.mod) 145 | 146 | inv = self.invm(self.pb) 147 | if rab == mulm(self.pa, inv, self.mod): 148 | self.match = True 149 | 150 | def createLogProof(self, version, x): 151 | randExponent = createRandomExponent() 152 | c = sha256(version + str(pow(self.gen, randExponent, self.mod))) 153 | d = (randExponent - mulm(x, c, self.modOrder)) % self.modOrder 154 | return (c, d) 155 | 156 | def checkLogProof(self, version, g, c, d): 157 | gd = pow(self.gen, d, self.mod) 158 | gc = pow(g, c, self.mod) 159 | gdgc = gd * gc % self.mod 160 | return (sha256(version + str(gdgc)) == c) 161 | 162 | def createCoordsProof(self, version, g2, g3, r): 163 | r1 = createRandomExponent() 164 | r2 = createRandomExponent() 165 | 166 | tmp1 = pow(g3, r1, self.mod) 167 | tmp2 = mulm(pow(self.gen, r1, self.mod), pow(g2, r2, self.mod), self.mod) 168 | 169 | c = sha256(version + str(tmp1) + str(tmp2)) 170 | 171 | # TODO: make a subm function 172 | d1 = (r1 - mulm(r, c, self.modOrder)) % self.modOrder 173 | d2 = (r2 - mulm(self.secret, c, self.modOrder)) % self.modOrder 174 | 175 | return (c, d1, d2) 176 | 177 | def checkCoordsProof(self, version, c, d1, d2, g2, g3, p, q): 178 | tmp1 = mulm(pow(g3, d1, self.mod), pow(p, c, self.mod), self.mod) 179 | 180 | tmp2 = mulm(mulm(pow(self.gen, d1, self.mod), pow(g2, d2, self.mod), self.mod), pow(q, c, self.mod), self.mod) 181 | 182 | cprime = sha256(version + str(tmp1) + str(tmp2)) 183 | 184 | return (c == cprime) 185 | 186 | def createEqualLogsProof(self, version, qa, qb, x): 187 | r = createRandomExponent() 188 | tmp1 = pow(self.gen, r, self.mod) 189 | qab = mulm(qa, qb, self.mod) 190 | tmp2 = pow(qab, r, self.mod) 191 | 192 | c = sha256(version + str(tmp1) + str(tmp2)) 193 | tmp1 = mulm(x, c, self.modOrder) 194 | d = (r - tmp1) % self.modOrder 195 | 196 | return (c, d) 197 | 198 | def checkEqualLogs(self, version, c, d, g3, qab, r): 199 | tmp1 = mulm(pow(self.gen, d, self.mod), pow(g3, c, self.mod), self.mod) 200 | 201 | tmp2 = mulm(pow(qab, d, self.mod), pow(r, c, self.mod), self.mod) 202 | 203 | cprime = sha256(version + str(tmp1) + str(tmp2)) 204 | return (c == cprime) 205 | 206 | def invm(self, x): 207 | return pow(x, self.mod-2, self.mod) 208 | 209 | def isValidArgument(self, val): 210 | return (val >= 2 and val <= self.mod-2) 211 | 212 | def packList(*items): 213 | buff = '' 214 | 215 | # For each item in the list, convert it to a byte string and add its length as a prefix 216 | for item in items: 217 | bytes = longToBytes(item) 218 | buff += struct.pack('!I', len(bytes)) + bytes 219 | 220 | return buff 221 | 222 | def unpackList(buff): 223 | items = [] 224 | 225 | index = 0 226 | while index < len(buff): 227 | # Get the length of the long (4 byte int before the actual long) 228 | length = struct.unpack('!I', buff[index:index+4])[0] 229 | index += 4 230 | 231 | # Convert the data back to a long and add it to the list 232 | item = bytesToLong(buff[index:index+length]) 233 | items.append(item) 234 | index += length 235 | 236 | return items 237 | 238 | def bytesToLong(bytes): 239 | length = len(bytes) 240 | string = 0 241 | for i in range(length): 242 | string += byteToLong(bytes[i:i+1]) << 8*(length-i-1) 243 | return string 244 | 245 | def longToBytes(long): 246 | bytes = '' 247 | while long != 0: 248 | bytes = longToByte(long & 0xff) + bytes 249 | long >>= 8 250 | return bytes 251 | 252 | def byteToLong(byte): 253 | return struct.unpack('B', byte)[0] 254 | 255 | def longToByte(long): 256 | return struct.pack('B', long) 257 | 258 | def mulm(x, y, mod): 259 | return x * y % mod 260 | 261 | def createRandomExponent(): 262 | return random.getrandbits(512*8) 263 | 264 | def sha256(message): 265 | return long(hashlib.sha256(str(message)).hexdigest(), 16) 266 | -------------------------------------------------------------------------------- /unmessage/ui.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | from functools import wraps 3 | 4 | from twisted.internet.defer import Deferred 5 | 6 | from . import errors 7 | 8 | 9 | class _Ui(object): 10 | def notify(self, notification): 11 | pass 12 | 13 | def notify_error(self, error): 14 | pass 15 | 16 | 17 | class ConversationUi(_Ui): 18 | def notify_disconnect(self, notification): 19 | pass 20 | 21 | def notify_offline(self, notification): 22 | pass 23 | 24 | def notify_online(self, notification): 25 | pass 26 | 27 | def notify_message(self, notification): 28 | pass 29 | 30 | def notify_in_authentication(self, notification): 31 | pass 32 | 33 | def notify_out_authentication(self, notification): 34 | pass 35 | 36 | def notify_finished_authentication(self, notification): 37 | pass 38 | 39 | def notify_in_file_request(self, notification): 40 | pass 41 | 42 | def notify_finished_in_file(self, notification): 43 | pass 44 | 45 | def notify_finished_out_file(self, notification): 46 | pass 47 | 48 | 49 | class PeerUi(_Ui): 50 | def notify_bootstrap(self, notification): 51 | pass 52 | 53 | def notify_peer_started(self, notification): 54 | pass 55 | 56 | def notify_peer_failed(self, notification): 57 | pass 58 | 59 | def notify_in_request(self, notification): 60 | pass 61 | 62 | def notify_conv_established(self, notification): 63 | pass 64 | 65 | 66 | def displays_error(f, display): 67 | @wraps(f) 68 | def wrapped_f(self, *args, **kwargs): 69 | try: 70 | result = f(self, *args, **kwargs) 71 | except Exception as e: 72 | display(self, errors.to_unmessage_error(e)) 73 | else: 74 | if isinstance(result, Deferred): 75 | value = Deferred() 76 | result.addErrback( 77 | lambda failure: display( 78 | self, 79 | errors.to_unmessage_error(failure))) 80 | result.addCallbacks(value.callback, value.errback) 81 | else: 82 | value = result 83 | return value 84 | return wrapped_f 85 | 86 | 87 | def displays_result(f, display): 88 | @wraps(f) 89 | def wrapped_f(self, *args, **kwargs): 90 | def _display(r): 91 | if r is not None: 92 | display(self, r) 93 | return r 94 | 95 | result = f(self, *args, **kwargs) 96 | if isinstance(result, Deferred): 97 | value = Deferred() 98 | result.addCallback(_display) 99 | result.addCallbacks(value.callback, value.errback) 100 | else: 101 | _display(result) 102 | value = result 103 | return value 104 | return wrapped_f 105 | 106 | 107 | def create_arg_parser(description, name, local_server_ip, 108 | tor_socks_port, tor_control_port, 109 | add_remote_mode=False): 110 | parser = ArgumentParser(description=description) 111 | 112 | parser.add_argument('-n', '--name', 113 | default=name) 114 | parser.add_argument('-i', '--local-server-ip', 115 | default=local_server_ip) 116 | parser.add_argument('-l', '--local-server-port', 117 | default=None, 118 | type=int) 119 | parser.add_argument('--connect-to-tor', 120 | action='store_false') 121 | parser.add_argument('-s', '--tor-socks-port', 122 | default=tor_socks_port, 123 | type=int) 124 | parser.add_argument('-c', '--tor-control-port', 125 | default=tor_control_port, 126 | type=int) 127 | if add_remote_mode: 128 | parser.add_argument('-r', '--remote-mode', 129 | action='store_true') 130 | parser.add_argument('-L', '--local-mode', 131 | action='store_true') 132 | 133 | return parser 134 | -------------------------------------------------------------------------------- /unmessage/untalk.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | from ctypes import CFUNCTYPE, cdll, c_char_p, c_int 3 | from Queue import Queue 4 | from threading import Thread 5 | from time import time 6 | 7 | import attr 8 | import pyaudio 9 | import pyaxo 10 | from nacl.secret import SecretBox 11 | from opuslib.api import constants as opus_constants 12 | from opuslib.api import ctl as opus_ctl 13 | from opuslib.api import decoder as opus_decoder 14 | from opuslib.api import encoder as opus_encoder 15 | from pyaxo import a2b 16 | from twisted.internet.defer import Deferred 17 | 18 | from . import errors 19 | from . import notifications 20 | from .elements import UntalkElement 21 | from .utils import raise_invalid_pub_key 22 | 23 | 24 | FORMAT = pyaudio.paInt16 25 | CHANNELS = 1 # mono 26 | SAMPLE_SIZE = 2 # 16 bits as bytes 27 | SAMPLE_RATE = 48000 # max rate 28 | FRAME_SIZE = 960 # 20 ms at 48000 29 | LOSS_PERCENTAGE = 2 30 | 31 | DEFAULT_DEVICE = 'default' 32 | INPUT_DEVICE = DEFAULT_DEVICE 33 | OUTPUT_DEVICE = 'pulse' 34 | 35 | DECODE_FEC = False 36 | 37 | MAC_SIZE = 16 38 | 39 | 40 | @attr.s 41 | class UntalkSession(object): 42 | type_ = UntalkElement.type_ 43 | element_classes = [UntalkElement] 44 | 45 | state_sent = 'sent' 46 | state_received = 'rcv' 47 | state_talking = 'talk' 48 | state_stopped = 'stop' 49 | 50 | conversation = attr.ib() 51 | other_handshake_key = attr.ib( 52 | validator=attr.validators.optional(raise_invalid_pub_key), 53 | default=None) 54 | connection = attr.ib(init=False, default=None) 55 | handshake_keys = attr.ib( 56 | init=False, 57 | default=attr.Factory(pyaxo.generate_keypair)) 58 | state = attr.ib(init=False) 59 | shared_key = attr.ib(init=False, default=None) 60 | 61 | thread_listen = attr.ib(init=False) 62 | thread_speak = attr.ib(init=False) 63 | 64 | jitter_buffer = attr.ib(init=False, default=None) 65 | codec = attr.ib(init=False, default=None) 66 | audio_listen = attr.ib(init=False) 67 | audio_speak = attr.ib(init=False) 68 | stream_in = attr.ib(init=False, default=None) 69 | stream_out = attr.ib(init=False, default=None) 70 | 71 | input_device = attr.ib(init=False, default=None) 72 | output_device = attr.ib(init=False, default=None) 73 | frame_size = attr.ib(init=False, default=FRAME_SIZE) 74 | loss_percentage = attr.ib(init=False, default=LOSS_PERCENTAGE) 75 | decode_fec = attr.ib(init=False, default=DECODE_FEC) 76 | 77 | def __attrs_post_init__(self): 78 | if self.other_handshake_key: 79 | self.state = UntalkSession.state_received 80 | else: 81 | self.state = UntalkSession.state_sent 82 | 83 | self.thread_listen = Thread(target=self.listen) 84 | self.thread_listen.daemon = True 85 | self.thread_speak = Thread(target=self.speak) 86 | self.thread_speak.daemon = True 87 | 88 | with suppress_alsa_errors(): 89 | self.audio_listen = pyaudio.PyAudio() 90 | self.audio_speak = pyaudio.PyAudio() 91 | 92 | @classmethod 93 | def parse_untalk_element(cls, element, conversation, connection=None): 94 | if conversation.untalk_session: 95 | if (conversation.untalk_session.state == cls.state_sent): 96 | # the other peer has accepted the request 97 | conversation.start_untalk( 98 | other_handshake_key=a2b(str(element))) 99 | else: 100 | conversation.init_untalk(connection, 101 | other_handshake_key=a2b(str(element))) 102 | 103 | conversation.ui.notify( 104 | notifications.UntalkNotification( 105 | '{} wishes to start a voice conversation'.format( 106 | conversation.contact.name))) 107 | 108 | @property 109 | def is_talking(self): 110 | return self.state == UntalkSession.state_talking 111 | 112 | @property 113 | def packet_length(self): 114 | return float(self.frame_size) / SAMPLE_RATE * 1000 115 | 116 | @property 117 | def decoded_size(self): 118 | return SAMPLE_SIZE * self.frame_size * CHANNELS 119 | 120 | @property 121 | def encoded_size(self): 122 | return self.decoded_size / 15 123 | 124 | @property 125 | def encrypted_size(self): 126 | return SecretBox.NONCE_SIZE + MAC_SIZE + self.encoded_size 127 | 128 | def configure(self, input_device=None, output_device=None, 129 | frame_size=None, loss_percentage=None, 130 | decode_fec=None): 131 | devices = get_audio_devices() 132 | if not devices: 133 | raise NoAudioDevicesAvailableError() 134 | 135 | if input_device is None: 136 | try: 137 | self.input_device = devices[INPUT_DEVICE] 138 | except KeyError: 139 | raise DefaultAudioDeviceNotFoundError(direction='input') 140 | else: 141 | self.input_device = int(input_device) 142 | 143 | if output_device is None: 144 | try: 145 | self.output_device = devices[OUTPUT_DEVICE] 146 | except KeyError: 147 | try: 148 | self.output_device = devices[DEFAULT_DEVICE] 149 | except KeyError: 150 | raise DefaultAudioDeviceNotFoundError(direction='output') 151 | else: 152 | self.output_device = int(output_device) 153 | 154 | if self.input_device not in devices.values(): 155 | raise AudioDeviceNotFoundError(direction='input', 156 | index=self.input_device) 157 | if self.output_device not in devices.values(): 158 | raise AudioDeviceNotFoundError(direction='output', 159 | index=self.output_device) 160 | 161 | if frame_size: 162 | self.frame_size = int(frame_size) 163 | if loss_percentage is not None: 164 | self.loss_percentage = int(loss_percentage) 165 | if decode_fec is not None: 166 | self.decode_fec = int(decode_fec) 167 | 168 | self.jitter_buffer = Queue() 169 | self.codec = OpusCodec(self.frame_size, 170 | self.loss_percentage, 171 | self.decode_fec) 172 | 173 | def start(self, other_handshake_key=None): 174 | if other_handshake_key: 175 | self.other_handshake_key = other_handshake_key 176 | 177 | # the handshake key was passed because the other peer accepted the 178 | # request, sent their key and are ready to receive audio 179 | speak_first = True 180 | else: 181 | # the handshake key was not passed because it was previously 182 | # received along with the request, which means this peer is already 183 | # able to derive the shared key and start listening, but cannot 184 | # speak until the other peer receives the confirmation with the key 185 | speak_first = False 186 | 187 | self.shared_key = pyaxo.generate_3dh( 188 | self.conversation.peer.identity_keys.priv, 189 | self.handshake_keys.priv, 190 | self.conversation.contact.key, 191 | self.other_handshake_key, 192 | mode=self.state == UntalkSession.state_received) 193 | 194 | # configure adaptive jitter buffer (AJB) 195 | self.oldtime = time() * 1000. 196 | self.jitter = 0 197 | self.beta = 1. / 8. 198 | self.qsize = 10 199 | 200 | self.state = UntalkSession.state_talking 201 | 202 | self.thread_listen.start() 203 | if speak_first: 204 | self.thread_speak.start() 205 | 206 | self.conversation.ui.notify(notifications.UntalkNotification( 207 | 'conversation with {} has started'.format( 208 | self.conversation.contact.name))) 209 | 210 | def stop(self): 211 | if self.connection: 212 | self.connection.remove_manager() 213 | self.state = UntalkSession.state_stopped 214 | 215 | self.conversation.ui.notify(notifications.UntalkNotification( 216 | 'conversation with {} has ended'.format( 217 | self.conversation.contact.name))) 218 | 219 | def notify_disconnect(self): 220 | self.connection = None 221 | self.conversation.remove_manager(self) 222 | 223 | def receive_data(self, data): 224 | if self.is_talking: 225 | # the handshake is complete and the peer is already listening 226 | 227 | if not self.thread_speak.is_alive(): 228 | # if the peer was not speaking already, now they are allowed to 229 | # do so because the other peer has completed the handshake as 230 | # well, is currently sending audio and therefore is also ready 231 | # to receive audio 232 | self.thread_speak.start() 233 | 234 | # lostcount += 1 235 | try: 236 | cipheraudio = data 237 | assert len(cipheraudio) == self.encrypted_size 238 | plainaudio = pyaxo.decrypt_symmetric(self.shared_key, 239 | cipheraudio) 240 | audio = self.codec.decode(plainaudio) 241 | if self.jitter_buffer.qsize() < self.qsize: 242 | self.jitter_buffer.put_nowait(audio) 243 | self.newtime = time() * 1000. 244 | self.jitter = ( 245 | (1. - self.beta)*self.jitter + 246 | (abs(self.newtime - self.oldtime - self.packet_length) - 247 | self.jitter)*self.beta) 248 | if self.jitter * 0.75 >= self.qsize and self.qsize < 35: 249 | self.qsize += 1 250 | elif self.jitter * 0.25 < self.qsize and self.qsize > 10: 251 | self.qsize -= 1 252 | self.oldtime = self.newtime 253 | except Exception as e: 254 | self.conversation.peer._ui.notify_error( 255 | errors.UntalkError( 256 | message='{}: {}'.format(str(type(e)), e.message))) 257 | else: 258 | # the handshake still has to be completed using the handshake key 259 | # within this data and is processed as a regular packet 260 | self.conversation.receive_data(data, self.connection) 261 | 262 | def send_data(self, data): 263 | d = Deferred() 264 | 265 | def send(): 266 | try: 267 | self.connection.send(data) 268 | except Exception as e: 269 | d.errback(errors.UntalkError( 270 | message='{}: {}'.format(str(type(e)), e.message))) 271 | else: 272 | d.callback(None) 273 | 274 | t = Thread(target=send) 275 | t.daemon = True 276 | t.start() 277 | 278 | return d 279 | 280 | def listen(self): 281 | try: 282 | self.stream_out = self.audio_listen.open( 283 | format=FORMAT, 284 | channels=CHANNELS, 285 | rate=SAMPLE_RATE, 286 | output=True, 287 | output_device_index=self.output_device) 288 | 289 | with suppress_alsa_errors(): 290 | while self.is_talking: 291 | audio = self.jitter_buffer.get() 292 | self.stream_out.write(audio) 293 | except Exception as e: 294 | self.conversation.peer._ui.notify_error( 295 | errors.UntalkError( 296 | message='{}: {}'.format(str(type(e)), e.message))) 297 | self.stop() 298 | self.audio_listen.terminate() 299 | 300 | def speak(self): 301 | try: 302 | self.stream_in = self.audio_speak.open( 303 | format=FORMAT, 304 | channels=CHANNELS, 305 | rate=SAMPLE_RATE, 306 | input=True, 307 | input_device_index=self.input_device, 308 | frames_per_buffer=self.frame_size) 309 | self.stream_in.stop_stream() 310 | self.stream_in.start_stream() 311 | 312 | with suppress_alsa_errors(): 313 | while self.is_talking: 314 | audio = self.stream_in.read(self.frame_size, 315 | exception_on_overflow=False) 316 | assert len(audio) == self.decoded_size 317 | plainaudio = self.codec.encode(audio) 318 | assert len(plainaudio) == self.encoded_size 319 | cipheraudio = pyaxo.encrypt_symmetric(self.shared_key, 320 | plainaudio) 321 | assert len(cipheraudio) == self.encrypted_size 322 | try: 323 | self.connection.send(cipheraudio) 324 | except AttributeError: 325 | # the peer has disconnected 326 | break 327 | except Exception as e: 328 | self.conversation.peer._ui.notify_error( 329 | errors.UntalkError( 330 | message='{}: {}'.format(str(type(e)), e.message))) 331 | self.stop() 332 | self.audio_speak.terminate() 333 | 334 | 335 | @attr.s 336 | class OpusCodec(object): 337 | """ 338 | opuslib from: 339 | https://github.com/OnBeep/opuslib 340 | OpusCodec class modified from: 341 | https://stackoverflow.com/questions/17728706/python-portaudio-opus-encoding-decoding 342 | """ 343 | frame_size = attr.ib(validator=attr.validators.instance_of(int)) 344 | loss_percentage = attr.ib(validator=attr.validators.instance_of(int)) 345 | decode_fec = attr.ib(validator=attr.validators.instance_of(int)) 346 | encoder = attr.ib(init=False) 347 | decoder = attr.ib(init=False) 348 | 349 | def __attrs_post_init__(self): 350 | self.encoder = opus_encoder.create(SAMPLE_RATE, 351 | CHANNELS, 352 | opus_constants.APPLICATION_VOIP) 353 | self.decoder = opus_decoder.create(SAMPLE_RATE, 354 | CHANNELS) 355 | 356 | # disable variable bitrate (VBR) 357 | opus_encoder.ctl(self.encoder, 358 | opus_ctl.set_vbr, 359 | 0) 360 | 361 | # configure expected jitter loss 362 | opus_encoder.ctl(self.encoder, 363 | opus_ctl.set_packet_loss_perc, 364 | self.loss_percentage) 365 | 366 | # configure forward error correction (FEC) 367 | opus_encoder.ctl(self.encoder, 368 | opus_ctl.set_inband_fec, 369 | self.decode_fec) 370 | 371 | def encode(self, data): 372 | return opus_encoder.encode(self.encoder, 373 | pcm=data, 374 | frame_size=self.frame_size, 375 | max_data_bytes=len(data)) 376 | 377 | def decode(self, data): 378 | return opus_decoder.decode(self.decoder, 379 | data, 380 | length=len(data), 381 | frame_size=self.frame_size, 382 | decode_fec=self.decode_fec, 383 | channels=CHANNELS) 384 | 385 | 386 | class AudioDeviceNotFoundError(errors.UntalkError): 387 | def __init__(self, direction, index): 388 | super(AudioDeviceNotFoundError, self).__init__( 389 | message='The {} device at index {} could not be ' 390 | 'found'.format(direction, index)) 391 | 392 | 393 | class DefaultAudioDeviceNotFoundError(errors.UntalkError): 394 | def __init__(self, direction): 395 | super(DefaultAudioDeviceNotFoundError, self).__init__( 396 | message='The {} device could not be found automatically - you ' 397 | 'must provide its index manually'.format(direction)) 398 | 399 | 400 | class NoAudioDevicesAvailableError(errors.UntalkError): 401 | def __init__(self): 402 | super(NoAudioDevicesAvailableError, self).__init__( 403 | message='There are no audio devices available') 404 | 405 | 406 | def get_audio_devices(): 407 | devices = dict() 408 | with suppress_alsa_errors(): 409 | audio = pyaudio.PyAudio() 410 | for i in range(audio.get_device_count()): 411 | d = audio.get_device_info_by_index(i) 412 | devices[d['name']] = d['index'] 413 | return devices 414 | 415 | 416 | """ 417 | Workaround functions to suppress ALSA error messages, taken from 418 | https://stackoverflow.com/a/17673011 419 | """ 420 | 421 | 422 | def handle_error(filename, line, function, err, fmt): 423 | pass 424 | 425 | 426 | ERROR_HANDLER_FUNC = CFUNCTYPE(None, 427 | c_char_p, c_int, c_char_p, c_int, c_char_p) 428 | C_ERROR_HANDLER = ERROR_HANDLER_FUNC(handle_error) 429 | 430 | 431 | @contextmanager 432 | def suppress_alsa_errors(): 433 | asound = cdll.LoadLibrary('libasound.so') 434 | asound.snd_lib_error_set_handler(C_ERROR_HANDLER) 435 | yield 436 | asound.snd_lib_error_set_handler(None) 437 | -------------------------------------------------------------------------------- /unmessage/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import re 4 | from functools import wraps 5 | 6 | import attr 7 | from nacl.public import PublicKey 8 | from twisted.internet.threads import deferToThread as fork 9 | 10 | from . import errors 11 | 12 | 13 | @attr.s 14 | class Serializable(object): 15 | filtered_attr_names = None 16 | 17 | @classmethod 18 | def filter_attrs(cls, attribute, value=None): 19 | if cls.filtered_attr_names is None: 20 | return True 21 | else: 22 | return attribute.name in cls.filtered_attr_names 23 | 24 | @classmethod 25 | def deserialize(cls, data): 26 | return cls(**json.loads(data)) 27 | 28 | def serialize(self): 29 | return json.dumps(attr.asdict(self, filter=self.filter_attrs)) 30 | 31 | 32 | def default_factory_attrib(factory, init=False, takes_self=True): 33 | return attr.ib(init=False, 34 | default=attr.Factory(factory, takes_self=takes_self)) 35 | 36 | 37 | @attr.s 38 | class Paths(object): 39 | head = attr.ib(validator=attr.validators.instance_of(str)) 40 | tail = attr.ib(validator=attr.validators.instance_of(str)) 41 | 42 | def __str__(self): 43 | return self.base 44 | 45 | @property 46 | def base(self): 47 | return os.path.join(self.head, self.tail) 48 | 49 | def join(self, *args): 50 | return os.path.join(self.base, *args) 51 | 52 | def to_new(self, new_tail): 53 | return Paths(self.base, new_tail) 54 | 55 | 56 | @attr.s 57 | class Address(object): 58 | host = attr.ib(attr.validators.instance_of(str)) 59 | port = attr.ib(attr.validators.instance_of(int)) 60 | 61 | 62 | @attr.s 63 | class Regex(object): 64 | pattern = attr.ib(attr.validators.instance_of(str)) 65 | 66 | def match(self, string, flags=0, match_end=True): 67 | end = '$' if match_end else '' 68 | return re.match(self.pattern + end, string, flags) 69 | 70 | def search(self, string, flags=0): 71 | return re.search(self.pattern, string, flags) 72 | 73 | 74 | Regex.peer_name = Regex(r'[a-zA-Z0-9_-]+') 75 | Regex.onion_domain = Regex(r'[a-z2-7]{16}\.onion') 76 | Regex.address_port = Regex(r'\d+') 77 | Regex.peer_identity = Regex(r'{}@{}:{}'.format(Regex.peer_name.pattern, 78 | Regex.onion_domain.pattern, 79 | Regex.address_port.pattern)) 80 | 81 | 82 | def raise_if_not(f, error=ValueError): 83 | @wraps(f) 84 | def raising_f(instance=None, attribute=None, value=None): 85 | if not f(value): 86 | raise error() 87 | return raising_f 88 | 89 | 90 | def is_valid_name(value): 91 | return (isinstance(value, str) and 92 | Regex.peer_name.match(value) is not None) 93 | 94 | 95 | raise_invalid_name = raise_if_not(is_valid_name, 96 | errors.InvalidNameError) 97 | 98 | 99 | def is_valid_identity(value): 100 | return (isinstance(value, str) and 101 | Regex.peer_identity.match(value) is not None) 102 | 103 | 104 | raise_invalid_identity = raise_if_not(is_valid_identity, 105 | errors.InvalidIdentityError) 106 | 107 | 108 | def is_valid_curve25519_key(value): 109 | return isinstance(value, bytes) and len(value) == PublicKey.SIZE 110 | 111 | 112 | def is_valid_priv_key(value): 113 | return is_valid_curve25519_key(value) 114 | 115 | 116 | raise_invalid_priv_key = raise_if_not(is_valid_priv_key, 117 | errors.InvalidPrivateKeyError) 118 | 119 | 120 | def is_valid_pub_key(value): 121 | return is_valid_curve25519_key(value) 122 | 123 | 124 | raise_invalid_pub_key = raise_if_not(is_valid_pub_key, 125 | errors.InvalidPublicKeyError) 126 | 127 | 128 | def is_valid_shared_key(value): 129 | return is_valid_curve25519_key(value) 130 | 131 | 132 | raise_invalid_shared_key = raise_if_not(is_valid_shared_key, 133 | errors.InvalidSharedKeyError) 134 | 135 | 136 | def is_valid_file_name(value): 137 | # TODO make a real file name validator 138 | try: 139 | expath = os.path.expanduser(value) 140 | assert expath == value 141 | abspath = os.path.abspath(value) 142 | assert abspath == os.path.join(os.getcwd(), value) 143 | head, tail = os.path.split(value) 144 | assert not len(head) and tail == value 145 | except: 146 | return False 147 | else: 148 | return True 149 | --------------------------------------------------------------------------------