pax_global_header00006660000000000000000000000064143324471340014517gustar00rootroot0000000000000052 comment=dc23058433b6f81892c40bccae592cceb146262b python-doubleratchet-1.0.3/000077500000000000000000000000001433244713400156645ustar00rootroot00000000000000python-doubleratchet-1.0.3/.flake8000066400000000000000000000001751433244713400170420ustar00rootroot00000000000000[flake8] max-line-length = 110 doctests = True ignore = E201,E202,W503 per-file-ignores = doubleratchet/project.py:E203 python-doubleratchet-1.0.3/.github/000077500000000000000000000000001433244713400172245ustar00rootroot00000000000000python-doubleratchet-1.0.3/.github/workflows/000077500000000000000000000000001433244713400212615ustar00rootroot00000000000000python-doubleratchet-1.0.3/.github/workflows/test-and-publish.yml000066400000000000000000000036301433244713400251710ustar00rootroot00000000000000name: Test & Publish on: [push, pull_request] permissions: contents: read jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.9"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install/update package management dependencies run: python -m pip install --upgrade pip setuptools wheel - name: Build and install python-doubleratchet run: pip install . - name: Install test dependencies run: pip install --upgrade pytest pytest-asyncio pytest-cov mypy pylint flake8 - name: Type-check using mypy run: mypy --strict doubleratchet/ setup.py examples/ tests/ - name: Lint using pylint run: pylint doubleratchet/ setup.py examples/ tests/ - name: Format-check using Flake8 run: flake8 doubleratchet/ setup.py examples/ tests/ - name: Test using pytest run: pytest --cov=doubleratchet --cov-report term-missing:skip-covered build: name: Build source distribution and wheel runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Build source distribution and wheel run: python3 setup.py sdist bdist_wheel - uses: actions/upload-artifact@v3 with: path: | dist/*.tar.gz dist/*.whl publish: needs: [test, build] runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/download-artifact@v3 with: name: artifact path: dist - uses: pypa/gh-action-pypi-publish@v1.5.1 with: user: __token__ password: ${{ secrets.pypi_token }} python-doubleratchet-1.0.3/.gitignore000066400000000000000000000001541433244713400176540ustar00rootroot00000000000000dist/ DoubleRatchet.egg-info/ __pycache__/ .pytest_cache/ .mypy_cache/ .coverage docs/_build/ examples/*/ python-doubleratchet-1.0.3/CHANGELOG.md000066400000000000000000000022431433244713400174760ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [1.0.3] - 8th of November 2022 ### Changed - Exclude tests from the packages ## [1.0.2] - 5th of November 2022 ### Changed - Fixed a bug in the way the storage models were versioned ## [1.0.1] - 3rd of November 2022 ### Added - Python 3.11 to the list of supported versions ### Removed - Unnecessary NotImplementedErrors in abstract methods ## [1.0.0] - 1st of November 2022 ### Added - Rewrite for modern, type safe Python 3. ### Removed - Pre-stable (i.e. versions before 1.0.0) changelog omitted. [Unreleased]: https://github.com/Syndace/python-doubleratchet/compare/v1.0.3...HEAD [1.0.3]: https://github.com/Syndace/python-doubleratchet/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/Syndace/python-doubleratchet/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/Syndace/python-doubleratchet/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/Syndace/python-doubleratchet/releases/tag/v1.0.0 python-doubleratchet-1.0.3/LICENSE000066400000000000000000000020711433244713400166710ustar00rootroot00000000000000The MIT License Copyright (c) 2022 Tim Henkes (Syndace) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-doubleratchet-1.0.3/MANIFEST.in000066400000000000000000000000371433244713400174220ustar00rootroot00000000000000include doubleratchet/py.typed python-doubleratchet-1.0.3/README.md000066400000000000000000000044771433244713400171570ustar00rootroot00000000000000[![PyPI](https://img.shields.io/pypi/v/DoubleRatchet.svg)](https://pypi.org/project/DoubleRatchet/) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/DoubleRatchet.svg)](https://pypi.org/project/DoubleRatchet/) [![Build Status](https://github.com/Syndace/python-doubleratchet/actions/workflows/test-and-publish.yml/badge.svg)](https://github.com/Syndace/python-doubleratchet/actions/workflows/test-and-publish.yml) [![Documentation Status](https://readthedocs.org/projects/python-doubleratchet/badge/?version=latest)](https://python-doubleratchet.readthedocs.io/) # python-doubleratchet # A Python implementation of the [Double Ratchet algorithm](https://signal.org/docs/specifications/doubleratchet/). ## Installation ## Install the latest release using pip (`pip install DoubleRatchet`) or manually from source by running `pip install .` in the cloned repository. ## Differences to the Specification ## This library implements the core of the Double Ratchet specification and includes a few of the recommended algorithms. This library does currently _not_ offer sophisticated decision mechanisms for the deletion of skipped message keys. Skipped message keys are only deleted when the maximum amount is reached and old keys are deleted from the storage in FIFO order. There is no time-based or event-based deletion. ## Testing, Type Checks and Linting ## python-doubleratchet uses [pytest](https://docs.pytest.org/en/latest/) as its testing framework, [mypy](http://mypy-lang.org/) for static type checks and both [pylint](https://pylint.pycqa.org/en/latest/) and [Flake8](https://flake8.pycqa.org/en/latest/) for linting. All tests/checks can be run locally with the following commands: ```sh $ pip install --upgrade pytest pytest-asyncio pytest-cov mypy pylint flake8 $ mypy --strict doubleratchet/ setup.py examples/ tests/ $ pylint doubleratchet/ setup.py examples/ tests/ $ flake8 doubleratchet/ setup.py examples/ tests/ $ pytest --cov=doubleratchet --cov-report term-missing:skip-covered ``` ## Documentation ## View the documentation on [readthedocs.io](https://python-doubleratchet.readthedocs.io/) or build it locally, which requires the Python packages listed in `docs/requirements.txt`. With all dependencies installed, run `make html` in the `docs/` directory. You can find the generated documentation in `docs/_build/html/`. python-doubleratchet-1.0.3/docs/000077500000000000000000000000001433244713400166145ustar00rootroot00000000000000python-doubleratchet-1.0.3/docs/Makefile000066400000000000000000000011431433244713400202530ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = DoubleRatchet SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) python-doubleratchet-1.0.3/docs/_static/000077500000000000000000000000001433244713400202425ustar00rootroot00000000000000python-doubleratchet-1.0.3/docs/_static/.gitkeep000066400000000000000000000000001433244713400216610ustar00rootroot00000000000000python-doubleratchet-1.0.3/docs/conf.py000066400000000000000000000064531433244713400201230ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a full list see # the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, add these # directories to sys.path here. If the directory is relative to the documentation root, # use os.path.abspath to make it absolute, like shown here. import os import sys this_file_path = os.path.dirname(os.path.abspath(__file__)) sys.path.append(os.path.join(this_file_path, "..", "doubleratchet")) from version import __version__ as __version from project import project as __project # -- Project information ----------------------------------------------------------------- project = __project["name"] author = __project["author"] copyright = f"{__project['year']}, {__project['author']}" # The short X.Y version version = __version["short"] # The full version, including alpha/beta/rc tags release = __version["full"] # -- General configuration --------------------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions coming # with Sphinx (named "sphinx.ext.*") or your custom ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon", "sphinx_autodoc_typehints" ] # Add any paths that contain templates here, relative to this directory. templates_path = [ "_templates" ] # List of patterns, relative to source directory, that match files and directories to # ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [ "_build", "Thumbs.db", ".DS_Store" ] # -- Options for HTML output ------------------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for a list of # builtin themes. html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, relative to # this directory. They are copied after the builtin static files, so a file named # "default.css" will overwrite the builtin "default.css". html_static_path = [ "_static" ] # -- Autodoc Configuration --------------------------------------------------------------- # The following two options seem to be ignored... autodoc_typehints = "description" autodoc_type_aliases = { type_alias: f"{type_alias}" for type_alias in { "JSONObject" } } def autodoc_skip_member_handler(app, what, name, obj, skip, options): # Skip private members, i.e. those that start with double underscores but do not end in underscores if name.startswith("__") and not name.endswith("_"): return True # Could be achieved using exclude-members, but this is more comfy if name in { "__abstractmethods__", "__dict__", "__module__", "__new__", "__weakref__", "_abc_impl" }: return True # Skip __init__s without documentation. Those are just used for type hints. if name == "__init__" and obj.__doc__ is None: return True return None def setup(app): app.connect("autodoc-skip-member", autodoc_skip_member_handler) python-doubleratchet-1.0.3/docs/doubleratchet/000077500000000000000000000000001433244713400214415ustar00rootroot00000000000000python-doubleratchet-1.0.3/docs/doubleratchet/aead.rst000066400000000000000000000002771433244713400230730ustar00rootroot00000000000000Module: aead ============ .. automodule:: doubleratchet.aead :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/diffie_hellman_ratchet.rst000066400000000000000000000003651433244713400266370ustar00rootroot00000000000000Module: diffie_hellman_ratchet ============================== .. automodule:: doubleratchet.diffie_hellman_ratchet :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/double_ratchet.rst000066400000000000000000000003351433244713400251600ustar00rootroot00000000000000Module: double_ratchet ====================== .. automodule:: doubleratchet.double_ratchet :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/kdf.rst000066400000000000000000000002741433244713400227420ustar00rootroot00000000000000Module: kdf =========== .. automodule:: doubleratchet.kdf :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/kdf_chain.rst000066400000000000000000000003161433244713400241010ustar00rootroot00000000000000Module: kdf_chain ================= .. automodule:: doubleratchet.kdf_chain :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/migrations.rst000066400000000000000000000004521433244713400243500ustar00rootroot00000000000000Module: migrations ================== Migrations between pydantic model versions, refer to :ref:`serialization_and_migration` .. automodule:: doubleratchet.migrations :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/models.rst000066400000000000000000000002311433244713400234520ustar00rootroot00000000000000Module: models ============== .. automodule:: doubleratchet.models :members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/package.rst000066400000000000000000000006701433244713400235710ustar00rootroot00000000000000Package: doubleratchet ====================== .. toctree:: Module: aead Module: diffie_hellman_ratchet Module: double_ratchet Module: kdf_chain Module: kdf Module: migrations Module: models Module: symmetric_key_ratchet Module: types Package: recommended python-doubleratchet-1.0.3/docs/doubleratchet/recommended/000077500000000000000000000000001433244713400237235ustar00rootroot00000000000000python-doubleratchet-1.0.3/docs/doubleratchet/recommended/aead_aes_hmac.rst000066400000000000000000000003461433244713400271720ustar00rootroot00000000000000Module: aead_aes_hmac ===================== .. automodule:: doubleratchet.recommended.aead_aes_hmac :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/recommended/diffie_hellman_ratchet_curve25519.rst000066400000000000000000000004421433244713400327270ustar00rootroot00000000000000Module: diffie_hellman_ratchet_curve25519 ========================================= .. automodule:: doubleratchet.recommended.diffie_hellman_ratchet_curve25519 :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/recommended/diffie_hellman_ratchet_curve448.rst000066400000000000000000000004341433244713400325620ustar00rootroot00000000000000Module: diffie_hellman_ratchet_curve448 ======================================= .. automodule:: doubleratchet.recommended.diffie_hellman_ratchet_curve448 :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/recommended/hash_function.rst000066400000000000000000000002721433244713400273060ustar00rootroot00000000000000Module: hash_function ===================== .. automodule:: doubleratchet.recommended.hash_function :members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/recommended/kdf_hkdf.rst000066400000000000000000000003271433244713400262170ustar00rootroot00000000000000Module: kdf_hkdf ================ .. automodule:: doubleratchet.recommended.kdf_hkdf :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/recommended/kdf_separate_hmacs.rst000066400000000000000000000003651433244713400302640ustar00rootroot00000000000000Module: kdf_separate_hmacs ========================== .. automodule:: doubleratchet.recommended.kdf_separate_hmacs :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/recommended/package.rst000066400000000000000000000005701433244713400260520ustar00rootroot00000000000000x3dh.recommended ================ .. toctree:: Module: aead_aes_hmac Module: diffie_hellman_ratchet_curve25519 Module: diffie_hellman_ratchet_curve448 Module: hash_function Module: kdf_hkdf Module: kdf_separate_hmacs python-doubleratchet-1.0.3/docs/doubleratchet/symmetric_key_ratchet.rst000066400000000000000000000003061433244713400265700ustar00rootroot00000000000000Module: symmetric_key_ratchet ============================= .. automodule:: doubleratchet.symmetric_key_ratchet :members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/doubleratchet/types.rst000066400000000000000000000002261433244713400233370ustar00rootroot00000000000000Module: types ============= .. automodule:: doubleratchet.types :members: :undoc-members: :member-order: bysource :show-inheritance: python-doubleratchet-1.0.3/docs/getting_started.rst000066400000000000000000000045121433244713400225370ustar00rootroot00000000000000Getting Started =============== This quick start guide assumes knowledge of the `Double Ratchet algorithm `_. Next to a few container classes and interfaces, the four major units of this library are :class:`~doubleratchet.double_ratchet.DoubleRatchet`, :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet`, :class:`~doubleratchet.symmetric_key_ratchet.SymmetricKeyRatchet` and :class:`~doubleratchet.kdf_chain.KDFChain`. These classes are structured roughly the same: * Instances can be created through factory methods (called e.g. ``create``), **NOT** by calling the constructor/``__init__``. * Instances can be serialized into JSON-friendly data structures. * When creating a new instance or deserializing an old instance, a set of configuration options has to be passed. Note that it is your responsibility to pass the same configuration when deserializing as you passed when creating the instance. * Some of the classes are abstract, requiring you to subclass them and to implement one or two abstract methods. * For some of the interfaces and abstract classes, implementations using recommended cryptographic primitives are available in the :doc:`doubleratchet.recommended ` package. The :class:`~doubleratchet.double_ratchet.DoubleRatchet` class offers a thin and simple message en-/decryption API, using and combining all of the other classes under the hood. For details on the configuration, refer to the :meth:`~doubleratchet.double_ratchet.DoubleRatchet.encrypt_initial_message` or :meth:`~doubleratchet.double_ratchet.DoubleRatchet.decrypt_initial_message` methods of the :class:`~doubleratchet.double_ratchet.DoubleRatchet` class. Take a look at the Double Ratchet Chat example in the python-doubleratchet repository for an example of a full configuration, including the required subclassing and using some of the recommended implementations. This library implements the core of the Double Ratchet specification and includes a few of the recommended algorithms. This library does currently *not* offer sophisticated decision mechanisms for the deletion of skipped message keys. Skipped message keys are only deleted when the maximum amount is reached and old keys are deleted from the storage in FIFO order. There is no time-based or event-based deletion. python-doubleratchet-1.0.3/docs/index.rst000066400000000000000000000004411433244713400204540ustar00rootroot00000000000000python-doubleratchet - A Python implementation of the Double Ratchet algorithm. =============================================================================== .. toctree:: installation getting_started serialization_and_migration API Documentation python-doubleratchet-1.0.3/docs/installation.rst000066400000000000000000000002571433244713400220530ustar00rootroot00000000000000Installation ============ Install the latest release using pip (``pip install DoubleRatchet``) or manually from source by running ``pip install .`` in the cloned repository. python-doubleratchet-1.0.3/docs/make.bat000066400000000000000000000014611433244713400202230ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build set SPHINXPROJ=DoubleRatchet if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd python-doubleratchet-1.0.3/docs/requirements.txt000066400000000000000000000000611433244713400220750ustar00rootroot00000000000000sphinx sphinx-rtd-theme sphinx-autodoc-typehints python-doubleratchet-1.0.3/docs/serialization_and_migration.rst000066400000000000000000000022301433244713400251130ustar00rootroot00000000000000.. _serialization_and_migration: Serialization and Migration =========================== python-doubleratchet uses `pydantic `_ for serialization internally. All classes that support serialization offer a property called ``model`` which returns the internal state of the instance as a pydantic model, and a method called ``from_model`` to restore the instance from said model. However, while these properties/methods are available for public access, migrations can't automatically be performed when working with models directly. Instead, the property ``json`` is provided, which returns the internal state of the instance a JSON-friendly Python dictionary, and the method ``from_json``, which restores the instance *after* performing required migrations on the data. Unless you have a good reason to work with the models directly, stick to the JSON serialization APIs. Migration from pre-stable ------------------------- Migration from pre-stable is provided, however, since the class hierarchy and serialization concept has changed, only whole Double Ratchet objects can be migrated to stable. Use the ``from_json`` method as usual. python-doubleratchet-1.0.3/doubleratchet/000077500000000000000000000000001433244713400205115ustar00rootroot00000000000000python-doubleratchet-1.0.3/doubleratchet/__init__.py000066400000000000000000000030771433244713400226310ustar00rootroot00000000000000from .version import __version__ from .project import project from .aead import AEAD, AuthenticationFailedException, DecryptionFailedException from .diffie_hellman_ratchet import DiffieHellmanRatchet, DoSProtectionException, DuplicateMessageException from .double_ratchet import DoubleRatchet from .kdf import KDF from .kdf_chain import KDFChain from .migrations import InconsistentSerializationException from .models import DiffieHellmanRatchetModel, DoubleRatchetModel, KDFChainModel, SymmetricKeyRatchetModel from .symmetric_key_ratchet import Chain, ChainNotAvailableException, SymmetricKeyRatchet from .types import EncryptedMessage, Header, JSONObject, SkippedMessageKeys # Fun: # https://github.com/PyCQA/pylint/issues/6006 # https://github.com/python/mypy/issues/10198 __all__ = [ # pylint: disable=unused-variable # .version "__version__", # .project "project", # .aead "AEAD", "AuthenticationFailedException", "DecryptionFailedException", # .diffie_hellman_ratchet "DiffieHellmanRatchet", "DoSProtectionException", "DuplicateMessageException", # .double_ratchet "DoubleRatchet", # .kdf "KDF", # .kdf_chain "KDFChain", # .migrations "InconsistentSerializationException", # .models "DiffieHellmanRatchetModel", "DoubleRatchetModel", "KDFChainModel", "SymmetricKeyRatchetModel", # .symmetric_key_ratchet "Chain", "ChainNotAvailableException", "SymmetricKeyRatchet", # .types "EncryptedMessage", "Header", "JSONObject", "SkippedMessageKeys" ] python-doubleratchet-1.0.3/doubleratchet/aead.py000066400000000000000000000030471433244713400217610ustar00rootroot00000000000000from abc import ABC, abstractmethod __all__ = [ # pylint: disable=unused-variable "AEAD", "AuthenticationFailedException", "DecryptionFailedException" ] class AuthenticationFailedException(Exception): """ Raised by :meth:`AEAD.decrypt` in case of authentication failure. """ class DecryptionFailedException(Exception): """ Raised by :meth:`AEAD.decrypt` in case of decryption failure. """ class AEAD(ABC): """ Authenticated Encryption with Associated Data (AEAD). """ @staticmethod @abstractmethod async def encrypt(plaintext: bytes, key: bytes, associated_data: bytes) -> bytes: """ Args: plaintext: The plaintext to encrypt. key: The encryption key. associated_data: Additional data to authenticate without including it in the ciphertext. Returns: The ciphertext. """ @staticmethod @abstractmethod async def decrypt(ciphertext: bytes, key: bytes, associated_data: bytes) -> bytes: """ Args: ciphertext: The ciphertext to decrypt. key: The decryption key. associated_data: Additional data to authenticate without including it in the ciphertext. Returns: The plaintext. Raises: AuthenticationFailedException: if the message could not be authenticated using the associated data. DecryptionFailedException: if the decryption failed for a different reason (e.g. invalid padding). """ python-doubleratchet-1.0.3/doubleratchet/diffie_hellman_ratchet.py000066400000000000000000000441321433244713400255270ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable from abc import ABC, abstractmethod from collections import OrderedDict import json from typing import Optional, Tuple, Type, TypeVar, cast import warnings from .kdf import KDF from .kdf_chain import KDFChain from .migrations import InconsistentSerializationException, parse_diffie_hellman_ratchet_model from .models import DiffieHellmanRatchetModel from .symmetric_key_ratchet import Chain, SymmetricKeyRatchet from .types import Header, JSONObject, SkippedMessageKeys __all__ = [ # pylint: disable=unused-variable "DiffieHellmanRatchet", "DoSProtectionException", "DuplicateMessageException" ] class DoSProtectionException(Exception): """ Raised by :meth:`DiffieHellmanRatchet.next_decryption_key` in case the number of skipped message keys to calculate crosses the DoS protection threshold. """ class DuplicateMessageException(Exception): """ Raised by :meth:`DiffieHellmanRatchet.next_decryption_key` in case is seems the message was processed before. """ DiffieHellmanRatchetTypeT = TypeVar("DiffieHellmanRatchetTypeT", bound="DiffieHellmanRatchet") class DiffieHellmanRatchet(ABC): """ As communication partners exchange messages they also exchange new Diffie-Hellman public keys, and the Diffie-Hellman output secrets become the inputs to the root chain. The output keys from the root chain become new KDF keys for the sending and receiving chains. This is called the Diffie-Hellman ratchet. https://signal.org/docs/specifications/doubleratchet/#diffie-hellman-ratchet Note: The specification introduces the symmetric-key ratchet and the Diffie-Hellman ratchet as independent units and links them together in the Double Ratchet. This implementation does not follow that separation, instead the Diffie-Hellman ratchet manages the symmetric-key ratchet internally, which makes the code a little less complicated, as the Double Ratchet doesn't have to forward keys generated by the Diffie-Hellman ratchet to the symmetric-key ratchet. """ def __init__(self) -> None: # Just the type definitions here self.__own_ratchet_priv: bytes self.__other_ratchet_pub: bytes self.__root_chain: KDFChain self.__dos_protection_threshold: int self.__symmetric_key_ratchet: SymmetricKeyRatchet @classmethod async def create( cls: Type[DiffieHellmanRatchetTypeT], own_ratchet_priv: Optional[bytes], other_ratchet_pub: bytes, root_chain_kdf: Type[KDF], root_chain_key: bytes, message_chain_kdf: Type[KDF], message_chain_constant: bytes, dos_protection_threshold: int ) -> DiffieHellmanRatchetTypeT: """ Create and configure a Diffie-Hellman ratchet. Args: own_ratchet_priv: The ratchet private key to use initially with this instance. other_ratchet_pub: The ratchet public key of the other party. root_chain_kdf: The KDF to use for the root chain. The KDF must be capable of deriving 64 bytes. root_chain_key: The key to initialize the root chain with, consisting of 32 bytes. message_chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. message_chain_constant: The constant to feed into the sending and receiving KDF chains on each step. dos_protection_threshold: The maximum number of skipped message keys to calculate. If more than that number of message keys are skipped, the keys are not calculated to prevent being DoSed. Returns: A configured instance of :class:`DiffieHellmanRatchet`, capable of sending and receiving messages. """ if len(root_chain_key) != 32: raise ValueError("The initial key for the root chain must consist of 32 bytes.") self = cls() self.__root_chain = KDFChain.create(root_chain_kdf, root_chain_key) self.__dos_protection_threshold = dos_protection_threshold self.__symmetric_key_ratchet = SymmetricKeyRatchet.create(message_chain_kdf, message_chain_constant) if own_ratchet_priv is None: self.__own_ratchet_priv = self._generate_priv() self.__other_ratchet_pub = other_ratchet_pub await self.__replace_chain(Chain.SENDING) else: self.__own_ratchet_priv = own_ratchet_priv self.__other_ratchet_pub = other_ratchet_pub await self.__replace_chain(Chain.RECEIVING) self.__own_ratchet_priv = self._generate_priv() await self.__replace_chain(Chain.SENDING) return self @property def sending_chain_length(self) -> int: """ Returns: The length of the sending chain of the internal symmetric-key ratchet. """ # Sanity check; the sending chain must exist assert self.__symmetric_key_ratchet.sending_chain_length is not None return self.__symmetric_key_ratchet.sending_chain_length @property def receiving_chain_length(self) -> Optional[int]: """ Returns: The length of the receiving chain of the internal symmetric-key ratchet, if it exists. """ return self.__symmetric_key_ratchet.receiving_chain_length #################### # abstract methods # #################### @staticmethod @abstractmethod def _generate_priv() -> bytes: """ Returns: A freshly generated private key, capable of performing Diffie-Hellman key exchanges with the public key of another party. Note: This function is recommended to generate a key pair based on the Curve25519 or Curve448 elliptic curves (https://signal.org/docs/specifications/doubleratchet/#recommended-cryptographic-algorithms). """ @staticmethod @abstractmethod def _derive_pub(priv: bytes) -> bytes: """ Derive the public key from a private key as generated by :meth:`_generate_priv`. Args: priv: The private key as returned by :meth:`_generate_priv`. Returns: The public key corresponding to the private key. """ @staticmethod @abstractmethod def _perform_diffie_hellman(own_priv: bytes, other_pub: bytes) -> bytes: """ Args: own_priv: The own ratchet private key. other_pub: The ratchet public key of the other party. Returns: A shared secret agreed on via Diffie-Hellman. This method is recommended to perform X25519 or X448. There is no need to check for invalid public keys (https://signal.org/docs/specifications/doubleratchet/#recommended-cryptographic-algorithms). """ ################# # serialization # ################# @property def model(self) -> DiffieHellmanRatchetModel: """ Returns: The internal state of this :class:`DiffieHellmanRatchet` as a pydantic model. """ return DiffieHellmanRatchetModel( own_ratchet_priv=self.__own_ratchet_priv, other_ratchet_pub=self.__other_ratchet_pub, root_chain=self.__root_chain.model, symmetric_key_ratchet=self.__symmetric_key_ratchet.model ) @property def json(self) -> JSONObject: """ Returns: The internal state of this :class:`DiffieHellmanRatchet` as a JSON-serializable Python object. """ return cast(JSONObject, json.loads(self.model.json())) @classmethod def from_model( cls: Type[DiffieHellmanRatchetTypeT], model: DiffieHellmanRatchetModel, root_chain_kdf: Type[KDF], message_chain_kdf: Type[KDF], message_chain_constant: bytes, dos_protection_threshold: int ) -> DiffieHellmanRatchetTypeT: """ Args: model: The pydantic model holding the internal state of a :class:`DiffieHellmanRatchet`, as produced by :attr:`model`. root_chain_kdf: The KDF to use for the root chain. The KDF must be capable of deriving 64 bytes. message_chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. message_chain_constant: The constant to feed into the sending and receiving KDF chains on each step. dos_protection_threshold: The maximum number of skipped message keys to calculate. If more than that number of message keys are skipped, the keys are not calculated to prevent being DoSed. Returns: A configured instance of :class:`DiffieHellmanRatchet`, with internal state restored from the model. Raises: InconsistentSerializationException: if the serialized data is structurally correct, but incomplete. This can only happen when migrating a :class:`~doubleratchet.double_ratchet.DoubleRatchet` instance from pre-stable data that was serialized before sending or receiving a single message. In this case, the serialized instance is basically uninitialized and can be discarded/replaced with a new instance without losing information. Warning: Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the documentation for details. """ self = cls() self.__own_ratchet_priv = model.own_ratchet_priv self.__other_ratchet_pub = model.other_ratchet_pub self.__root_chain = KDFChain.from_model(model.root_chain, root_chain_kdf) self.__dos_protection_threshold = dos_protection_threshold self.__symmetric_key_ratchet = SymmetricKeyRatchet.from_model( model.symmetric_key_ratchet, message_chain_kdf, message_chain_constant ) if self.__symmetric_key_ratchet.sending_chain_length is None: raise InconsistentSerializationException( "The restored internal state does not contain an initialized sending chain." ) return self @classmethod def from_json( cls: Type[DiffieHellmanRatchetTypeT], serialized: JSONObject, root_chain_kdf: Type[KDF], message_chain_kdf: Type[KDF], message_chain_constant: bytes, dos_protection_threshold: int ) -> DiffieHellmanRatchetTypeT: """ Args: serialized: A JSON-serializable Python object holding the internal state of a :class:`DiffieHellmanRatchet`, as produced by :attr:`json`. root_chain_kdf: The KDF to use for the root chain. The KDF must be capable of deriving 64 bytes. message_chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. message_chain_constant: The constant to feed into the sending and receiving KDF chains on each step. dos_protection_threshold: The maximum number of skipped message keys to calculate. If more than that number of message keys are skipped, the keys are not calculated to prevent being DoSed. Returns: A configured instance of :class:`DiffieHellmanRatchet`, with internal state restored from the serialized data. Raises: InconsistentSerializationException: if the serialized data is structurally correct, but incomplete. This can only happen when migrating a :class:`~doubleratchet.double_ratchet.DoubleRatchet` instance from pre-stable data that was serialized before sending or receiving a single message. In this case, the serialized instance is basically uninitialized and can be discarded/replaced with a new instance without losing information. Warning: Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the documentation for details. """ return cls.from_model( parse_diffie_hellman_ratchet_model(serialized), root_chain_kdf, message_chain_kdf, message_chain_constant, dos_protection_threshold ) ###################### # ratchet management # ###################### async def __replace_chain(self, chain: Chain) -> None: """ Replace one of the chains of the internal symmetric-key ratchet. The chain key is derived by feeding the Diffie-Hellman shared secret to the root chain. Args: chain: The chain to replace. """ self.__symmetric_key_ratchet.replace_chain(chain, await self.__root_chain.step( self._perform_diffie_hellman(self.__own_ratchet_priv, self.__other_ratchet_pub), 32 )) async def next_encryption_key(self) -> Tuple[bytes, Header]: """ Returns: The next (32 bytes) encryption key derived from the sending chain and the corresponding Diffie-Hellman ratchet header. """ sending_chain_length = self.__symmetric_key_ratchet.sending_chain_length assert sending_chain_length is not None # sanity check previous_sending_chain_length = self.__symmetric_key_ratchet.previous_sending_chain_length or 0 header = Header( ratchet_pub=self._derive_pub(self.__own_ratchet_priv), previous_sending_chain_length=previous_sending_chain_length, sending_chain_length=sending_chain_length ) next_encryption_key = await self.__symmetric_key_ratchet.next_encryption_key() return next_encryption_key, header async def next_decryption_key(self, header: Header) -> Tuple[bytes, SkippedMessageKeys]: """ Args: header: The Diffie-Hellman ratchet header, Returns: The next (32 bytes) decryption key derived from the receiving chain and message keys that were skipped while deriving the new decryption key. Raises: DoSProtectionException: if a huge number of message keys were skipped that have to be calculated first before decrypting the next message. DuplicateMessageException: if this message appears to be a duplicate. """ skipped_message_keys: SkippedMessageKeys = OrderedDict() # Perform a ratchet step if the ratchet public keys differ if header.ratchet_pub != self.__other_ratchet_pub: # If there is a receiving chain, calculate skipped message keys before replacing the chain receiving_chain_length = self.__symmetric_key_ratchet.receiving_chain_length if receiving_chain_length is not None: # Check whether the number of skipped message keys is within reasonable bounds num_skipped_keys = max(header.previous_sending_chain_length - receiving_chain_length, 0) if num_skipped_keys > self.__dos_protection_threshold: # This is a warning rather than an exception, to make sure that in case of heavy message # loss, the ratchet is not fully blocked from moving forward/"recovering" through a # ratchet step. warnings.warn( f"More than {self.__dos_protection_threshold} message keys skipped. Not calculating" " all of these message keys to prevent being DoSed." ) else: # Calculate the skipped message keys for _ in range(num_skipped_keys): skipped_message_keys[(self.__other_ratchet_pub, receiving_chain_length)] = \ await self.__symmetric_key_ratchet.next_decryption_key() receiving_chain_length += 1 # Perform one full ratchet step, by replacing both the receiving and the sending chains self.__other_ratchet_pub = header.ratchet_pub await self.__replace_chain(Chain.RECEIVING) self.__own_ratchet_priv = self._generate_priv() await self.__replace_chain(Chain.SENDING) # Once the chains are prepared, forward the receiving chain to the required key receiving_chain_length = self.__symmetric_key_ratchet.receiving_chain_length assert receiving_chain_length is not None # sanity check # Check whether the number of skipped message keys is within reasonable bounds num_skipped_keys = max(header.sending_chain_length - receiving_chain_length, 0) if num_skipped_keys > self.__dos_protection_threshold: raise DoSProtectionException( f"More than {self.__dos_protection_threshold} message keys skipped. Not calculating all of" " these message keys to prevent being DoSed." ) # Calculate the skipped message keys and keep the receiving chain length updated for _ in range(num_skipped_keys): skipped_message_keys[(self.__other_ratchet_pub, receiving_chain_length)] = \ await self.__symmetric_key_ratchet.next_decryption_key() receiving_chain_length += 1 # Check whether a message key is requested that was derived before if header.sending_chain_length < receiving_chain_length: raise DuplicateMessageException( f"It seems like this message was already decrypted before. Header: {header}" ) # Finally, derive the requested message key and return it with the skipped message keys next_decryption_key = await self.__symmetric_key_ratchet.next_decryption_key() return next_decryption_key, skipped_message_keys python-doubleratchet-1.0.3/doubleratchet/double_ratchet.py000066400000000000000000000507071433244713400240600ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable from abc import ABC, abstractmethod from collections import OrderedDict import copy import itertools import json from typing import Optional, Tuple, Type, TypeVar, cast from .aead import AEAD from .diffie_hellman_ratchet import DiffieHellmanRatchet from .kdf import KDF from .migrations import parse_double_ratchet_model from .models import DoubleRatchetModel, SkippedMessageKeyModel from .types import EncryptedMessage, Header, JSONObject, SkippedMessageKeys __all__ = [ # pylint: disable=unused-variable "DoubleRatchet" ] DoubleRatchetTypeT = TypeVar("DoubleRatchetTypeT", bound="DoubleRatchet") class DoubleRatchet(ABC): """ Combining the symmetric-key ratchet and the Diffie-Hellman ratchet gives the Double Ratchet. https://signal.org/docs/specifications/doubleratchet/#double-ratchet Note: In this implementation, the Diffie-Hellman ratchet already manages the symmetric-key ratchet internally, see :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet` for details. The Double Ratchet class adds message en-/decryption and offers a more convenient public API that handles lost and out-of-order messages. """ def __init__(self) -> None: # Just the type definitions here self.__max_num_skipped_message_keys: int self.__skipped_message_keys: SkippedMessageKeys self.__aead: Type[AEAD] self.__diffie_hellman_ratchet: DiffieHellmanRatchet @classmethod async def encrypt_initial_message( cls: Type[DoubleRatchetTypeT], diffie_hellman_ratchet_class: Type[DiffieHellmanRatchet], root_chain_kdf: Type[KDF], message_chain_kdf: Type[KDF], message_chain_constant: bytes, dos_protection_threshold: int, max_num_skipped_message_keys: int, aead: Type[AEAD], shared_secret: bytes, recipient_ratchet_pub: bytes, message: bytes, associated_data: bytes ) -> Tuple[DoubleRatchetTypeT, EncryptedMessage]: """ Args: diffie_hellman_ratchet_class: A non-abstract subclass of :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet`. root_chain_kdf: The KDF to use for the root chain. The KDF must be capable of deriving 64 bytes. message_chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. message_chain_constant: The constant to feed into the sending and receiving KDF chains on each step. dos_protection_threshold: The maximum number of skipped message keys to calculate. If more than that number of message keys are skipped, the keys are not calculated to prevent being DoSed. max_num_skipped_message_keys: The maximum number of skipped message keys to store in case the lost or out-of-order message comes in later. Older keys are discarded to make space for newer keys. aead: The AEAD implementation to use for message en- and decryption. shared_secret: A shared secret consisting of 32 bytes that was agreed on by means external to this protocol. recipient_ratchet_pub: The ratchet public key of the recipient. message: The initial message. associated_data: Additional data to authenticate without including it in the ciphertext. Returns: A configured instance of :class:`DoubleRatchet` ready to send and receive messages together with the initial message. """ if dos_protection_threshold > max_num_skipped_message_keys: raise ValueError( "The `dos_protection_threshold` can't be bigger than `max_num_skipped_message_keys`." ) if len(shared_secret) != 32: raise ValueError("The shared secret must consist of 32 bytes.") self = cls() self.__max_num_skipped_message_keys = max_num_skipped_message_keys self.__skipped_message_keys = OrderedDict() self.__aead = aead self.__diffie_hellman_ratchet = await diffie_hellman_ratchet_class.create( None, recipient_ratchet_pub, root_chain_kdf, shared_secret, message_chain_kdf, message_chain_constant, dos_protection_threshold ) message_key, header = await self.__diffie_hellman_ratchet.next_encryption_key() ciphertext = await self.__aead.encrypt( message, message_key, self._build_associated_data(associated_data, header) ) return (self, EncryptedMessage(header=header, ciphertext=ciphertext)) @classmethod async def decrypt_initial_message( cls: Type[DoubleRatchetTypeT], diffie_hellman_ratchet_class: Type[DiffieHellmanRatchet], root_chain_kdf: Type[KDF], message_chain_kdf: Type[KDF], message_chain_constant: bytes, dos_protection_threshold: int, max_num_skipped_message_keys: int, aead: Type[AEAD], shared_secret: bytes, own_ratchet_priv: bytes, message: EncryptedMessage, associated_data: bytes ) -> Tuple[DoubleRatchetTypeT, bytes]: """ Args: diffie_hellman_ratchet_class: A non-abstract subclass of :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet`. root_chain_kdf: The KDF to use for the root chain. The KDF must be capable of deriving 64 bytes. message_chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. message_chain_constant: The constant to feed into the sending and receiving KDF chains on each step. dos_protection_threshold: The maximum number of skipped message keys to calculate. If more than that number of message keys are skipped, the keys are not calculated to prevent being DoSed. max_num_skipped_message_keys: The maximum number of skipped message keys to store in case the lost or out-of-order message comes in later. Older keys are discarded to make space for newer keys. aead: The AEAD implementation to use for message en- and decryption. shared_secret: A shared secret that was agreed on by means external to this protocol. own_ratchet_priv: The ratchet private key to use initially. message: The encrypted initial message. associated_data: Additional data to authenticate without including it in the ciphertext. Returns: A configured instance of :class:`DoubleRatchet` ready to send and receive messages together with the decrypted initial message. Raises: AuthenticationFailedException: if the message could not be authenticated using the associated data. DecryptionFailedException: if the decryption failed for a different reason (e.g. invalid padding). DoSProtectionException: if a huge number of message keys were skipped that have to be calculated first before decrypting the message. """ if dos_protection_threshold > max_num_skipped_message_keys: raise ValueError( "The `dos_protection_threshold` can't be bigger than `max_num_skipped_message_keys`." ) if len(shared_secret) != 32: raise ValueError("The shared secret must consist of 32 bytes.") self = cls() self.__max_num_skipped_message_keys = max_num_skipped_message_keys self.__aead = aead self.__diffie_hellman_ratchet = await diffie_hellman_ratchet_class.create( own_ratchet_priv, message.header.ratchet_pub, root_chain_kdf, shared_secret, message_chain_kdf, message_chain_constant, dos_protection_threshold ) message_key, skipped_message_keys = \ await self.__diffie_hellman_ratchet.next_decryption_key(message.header) # Even the first message might have skipped message keys. The number of keys can't cross thresholds, # thus no FIFO discarding required. self.__skipped_message_keys = skipped_message_keys return (self, await self.__aead.decrypt( message.ciphertext, message_key, self._build_associated_data(associated_data, message.header) )) @property def sending_chain_length(self) -> int: """ Returns: The length of the sending chain of the internal symmetric-key ratchet, as exposed by the internal Diffie-Hellman ratchet. """ return self.__diffie_hellman_ratchet.sending_chain_length @property def receiving_chain_length(self) -> Optional[int]: """ Returns: The length of the receiving chain of the internal symmetric-key ratchet, if it exists, as exposed by the internal Diffie-Hellman ratchet. """ return self.__diffie_hellman_ratchet.receiving_chain_length #################### # abstract methods # #################### @staticmethod @abstractmethod def _build_associated_data(associated_data: bytes, header: Header) -> bytes: """ Args: associated_data: The associated data to prepend to the output. If the associated data is not guaranteed to be a parseable byte sequence, a length value should be prepended to ensure that the output is parseable as a unique pair (associated data, header). header: The message header to encode in a unique, reversible manner. Returns: A byte sequence encoding the associated data and the header in a unique, reversible way. """ ################# # serialization # ################# @property def model(self) -> DoubleRatchetModel: """ Returns: The internal state of this :class:`DoubleRatchet` as a pydantic model. """ return DoubleRatchetModel( diffie_hellman_ratchet=self.__diffie_hellman_ratchet.model, skipped_message_keys=[ SkippedMessageKeyModel( ratchet_pub=ratchet_pub, index=index, message_key=message_key ) for (ratchet_pub, index), message_key in self.__skipped_message_keys.items() ] ) @property def json(self) -> JSONObject: """ Returns: The internal state of this :class:`DoubleRatchet` as a JSON-serializable Python object. """ return cast(JSONObject, json.loads(self.model.json())) @classmethod def from_model( cls: Type[DoubleRatchetTypeT], model: DoubleRatchetModel, diffie_hellman_ratchet_class: Type[DiffieHellmanRatchet], root_chain_kdf: Type[KDF], message_chain_kdf: Type[KDF], message_chain_constant: bytes, dos_protection_threshold: int, max_num_skipped_message_keys: int, aead: Type[AEAD] ) -> DoubleRatchetTypeT: """ Args: model: The pydantic model holding the internal state of a :class:`DoubleRatchet`, as produced by :attr:`model`. diffie_hellman_ratchet_class: A non-abstract subclass of :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet`. root_chain_kdf: The KDF to use for the root chain. The KDF must be capable of deriving 64 bytes. message_chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. message_chain_constant: The constant to feed into the sending and receiving KDF chains on each step. dos_protection_threshold: The maximum number of skipped message keys to calculate. If more than that number of message keys are skipped, the keys are not calculated to prevent being DoSed. max_num_skipped_message_keys: The maximum number of skipped message keys to store in case the lost or out-of-order message comes in later. Older keys are discarded to make space for newer keys. aead: The AEAD implementation to use for message en- and decryption. Returns: A configured instance of :class:`DoubleRatchet`, with internal state restored from the model. Raises: InconsistentSerializationException: if the serialized data is structurally correct, but incomplete. This can only happen when migrating an instance from pre-stable data that was serialized before sending or receiving a single message. In this case, the serialized instance is basically uninitialized and can be discarded/replaced with a new instance using :meth:`encrypt_initial_message` or :meth:`decrypt_initial_message` without losing information. Warning: Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the documentation for details. """ if dos_protection_threshold > max_num_skipped_message_keys: raise ValueError( "The `dos_protection_threshold` can't be bigger than `max_num_skipped_message_keys`." ) self = cls() self.__max_num_skipped_message_keys = max_num_skipped_message_keys self.__skipped_message_keys = OrderedDict( ((smk.ratchet_pub, smk.index), smk.message_key) for smk in model.skipped_message_keys ) self.__aead = aead self.__diffie_hellman_ratchet = diffie_hellman_ratchet_class.from_model( model.diffie_hellman_ratchet, root_chain_kdf, message_chain_kdf, message_chain_constant, dos_protection_threshold ) return self @classmethod def from_json( cls: Type[DoubleRatchetTypeT], serialized: JSONObject, diffie_hellman_ratchet_class: Type[DiffieHellmanRatchet], root_chain_kdf: Type[KDF], message_chain_kdf: Type[KDF], message_chain_constant: bytes, dos_protection_threshold: int, max_num_skipped_message_keys: int, aead: Type[AEAD] ) -> DoubleRatchetTypeT: """ Args: serialized: A JSON-serializable Python object holding the internal state of a :class:`DoubleRatchet`, as produced by :attr:`json`. diffie_hellman_ratchet_class: A non-abstract subclass of :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet`. root_chain_kdf: The KDF to use for the root chain. The KDF must be capable of deriving 64 bytes. message_chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. message_chain_constant: The constant to feed into the sending and receiving KDF chains on each step. dos_protection_threshold: The maximum number of skipped message keys to calculate. If more than that number of message keys are skipped, the keys are not calculated to prevent being DoSed. max_num_skipped_message_keys: The maximum number of skipped message keys to store in case the lost or out-of-order message comes in later. Older keys are discarded to make space for newer keys. aead: The AEAD implementation to use for message en- and decryption. Returns: A configured instance of :class:`DoubleRatchet`, with internal state restored from the serialized data. Raises: InconsistentSerializationException: if the serialized data is structurally correct, but incomplete. This can only happen when migrating an instance from pre-stable data that was serialized before sending or receiving a single message. In this case, the serialized instance is basically uninitialized and can be discarded/replaced with a new instance using :meth:`encrypt_initial_message` or :meth:`decrypt_initial_message` without losing information. """ return cls.from_model( parse_double_ratchet_model(serialized), diffie_hellman_ratchet_class, root_chain_kdf, message_chain_kdf, message_chain_constant, dos_protection_threshold, max_num_skipped_message_keys, aead ) ######################### # message en/decryption # ######################### async def encrypt_message(self, message: bytes, associated_data: bytes) -> EncryptedMessage: """ Args: message: The message to encrypt. associated_data: Additional data to authenticate without including it in the ciphertext. Returns: The encrypted message including the header to send to the recipient. """ message_key, header = await self.__diffie_hellman_ratchet.next_encryption_key() ciphertext = await self.__aead.encrypt( message, message_key, self._build_associated_data(associated_data, header) ) return EncryptedMessage(header=header, ciphertext=ciphertext) async def decrypt_message(self, message: EncryptedMessage, associated_data: bytes) -> bytes: """ Args: message: The encrypted message. associated_data: Additional data to authenticate without including it in the ciphertext. Returns: The message plaintext, after decrypting and authenticating the ciphertext. Raises: AuthenticationFailedException: if the message could not be authenticated using the associated data. DecryptionFailedException: if the decryption failed for a different reason (e.g. invalid padding). DoSProtectionException: if a huge number of message keys were skipped that have to be calculated first before decrypting the message. DuplicateMessageException: if this message appears to be a duplicate. """ # Be careful to only keep changes to the internal state on decryption success. To do so, work with a # clone of the Diffie-Hellman ratchet, discard the clone on failure or replace the original with the # clone on success. # https://signal.org/docs/specifications/doubleratchet/#decrypting-messages diffie_hellman_ratchet = copy.deepcopy(self.__diffie_hellman_ratchet) skipped_message_keys: Optional[SkippedMessageKeys] = None skipped_message_key_key = (message.header.ratchet_pub, message.header.sending_chain_length) # Get the message key, either from the skipped message keys or from the Diffie-Hellman ratchet clone message_key: bytes try: message_key = self.__skipped_message_keys[skipped_message_key_key] except KeyError: message_key, skipped_message_keys = \ await diffie_hellman_ratchet.next_decryption_key(message.header) # Decrypt the message (or at least attempt to do so). At this point, the internal state of this # instance remains untouched. plaintext = await self.__aead.decrypt( message.ciphertext, message_key, self._build_associated_data(associated_data, message.header) ) # Following decryption success, apply relevant changes to the internal state. # In case a skipped message key was used, remove it. self.__skipped_message_keys.pop(skipped_message_key_key, None) # Store new skipped message keys and limit their number. if skipped_message_keys is not None: self.__skipped_message_keys.update(skipped_message_keys) self.__skipped_message_keys = OrderedDict(itertools.islice( self.__skipped_message_keys.items(), max(len(self.__skipped_message_keys) - self.__max_num_skipped_message_keys, 0), None )) # Store the clone. self.__diffie_hellman_ratchet = diffie_hellman_ratchet return plaintext python-doubleratchet-1.0.3/doubleratchet/kdf.py000066400000000000000000000017441433244713400216350ustar00rootroot00000000000000from abc import ABC, abstractmethod __all__ = [ # pylint: disable=unused-variable "KDF" ] class KDF(ABC): """ A KDF is defined as a cryptographic function that takes a secret and random KDF key and some input data and returns output data. The output data is indistinguishable from random provided the key isn't known (i.e. a KDF satisfies the requirements of a cryptographic "PRF"). If the key is not secret and random, the KDF should still provide a secure cryptographic hash of its key and input data. https://signal.org/docs/specifications/doubleratchet/#kdf-chains """ @staticmethod @abstractmethod async def derive(key: bytes, data: bytes, length: int) -> bytes: """ Args: key: The KDF key. data: The input data. length: The desired size of the output data, in bytes. Returns: ``length`` bytes of output data, derived from the KDF key and the input data. """ python-doubleratchet-1.0.3/doubleratchet/kdf_chain.py000066400000000000000000000077671433244713400230120ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable import json from typing import Type, TypeVar, cast from .kdf import KDF from .migrations import parse_kdf_chain_model from .models import KDFChainModel from .types import JSONObject __all__ = [ # pylint: disable=unused-variable "KDFChain" ] KDFChainTypeT = TypeVar("KDFChainTypeT", bound="KDFChain") class KDFChain: """ The term KDF chain is used when some of the output from a KDF is used as an output key and some is used to replace the KDF key, which can then be used with another input. https://signal.org/docs/specifications/doubleratchet/#kdf-chains """ def __init__(self) -> None: # Just the type definitions here self.__kdf: Type[KDF] self.__key: bytes self.__length: int @classmethod def create(cls: Type[KDFChainTypeT], kdf: Type[KDF], key: bytes) -> KDFChainTypeT: """ Args: kdf: The KDF to use for the derivation step. key: The initial chain key. Returns: A configured instance of :class:`KDFChain`. """ self = cls() self.__kdf = kdf self.__key = key self.__length = 0 return self @property def model(self) -> KDFChainModel: """ Returns: The internal state of this :class:`KDFChain` as a pydantic model. """ return KDFChainModel(length=self.__length, key=self.__key) @property def json(self) -> JSONObject: """ Returns: The internal state of this :class:`KDFChain` as a JSON-serializable Python object. """ return cast(JSONObject, json.loads(self.model.json())) @classmethod def from_model(cls: Type[KDFChainTypeT], model: KDFChainModel, kdf: Type[KDF]) -> KDFChainTypeT: """ Args: model: The pydantic model holding the internal state of a :class:`KDFChain`, as produced by :attr:`model`. kdf: The KDF to use for the derivation step. Returns: A configured instance of :class:`KDFChain`, with internal state restored from the model. Warning: Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the documentation for details. """ self = cls() self.__kdf = kdf self.__key = model.key self.__length = model.length return self @classmethod def from_json(cls: Type[KDFChainTypeT], serialized: JSONObject, kdf: Type[KDF]) -> KDFChainTypeT: """ Args: serialized: A JSON-serializable Python object holding the internal state of a :class:`KDFChain`, as produced by :attr:`json`. kdf: The KDF to use for the derivation step. Returns: A configured instance of :class:`KDFChain`, with internal state restored from the serialized data. """ return cls.from_model(parse_kdf_chain_model(serialized), kdf) async def step(self, data: bytes, length: int) -> bytes: """ Perform a ratchet step of this KDF chain. Args: data: The input data. length: The desired size of the output data, in bytes. Returns: ``length`` bytes of output data, derived from the internal KDF key and the input data. """ key_length = len(self.__key) output_data = await self.__kdf.derive(self.__key, data, key_length + length) self.__length += 1 self.__key = output_data[:key_length] return output_data[key_length:] @property def length(self) -> int: """ Returns: The length of this KDF chain, i.e. the number of steps that have been performed. """ return self.__length python-doubleratchet-1.0.3/doubleratchet/migrations.py000066400000000000000000000240051433244713400232400ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable import base64 from typing import Dict, List, Optional, cast from pydantic import BaseModel from .models import ( DiffieHellmanRatchetModel, DoubleRatchetModel, KDFChainModel, SkippedMessageKeyModel, SymmetricKeyRatchetModel ) from .types import JSONObject __all__ = [ # pylint: disable=unused-variable "InconsistentSerializationException", "parse_diffie_hellman_ratchet_model", "parse_double_ratchet_model", "parse_kdf_chain_model", "parse_symmetric_key_ratchet_model" ] class InconsistentSerializationException(Exception): """ Raised by :func:`parse_double_ratchet_model` in case data migration from pre-stable serialization format is performed, and the data is structurally correct, but incomplete. """ class PreStableSMKKeyModel(BaseModel): """ The pre-stable serialization format used JSON strings for the keys of the skipped message keys dictionary. This model describes the structure of those key JSON strings. """ pub: str index: int class PreStableKeyPairModel(BaseModel): """ This model describes how a key pair was serialized in pre-stable serialization format. """ priv: Optional[str] pub: Optional[str] class PreStableKDFChainModel(BaseModel): """ This model describes how a KDF chain was serialized in pre-stable serialization format. """ length: int key: str class PreStableDiffieHellmanRatchetModel(BaseModel): """ This model describes how Diffie-Hellman ratchet instances were serialized in pre-stable serialization format. """ root_chain: PreStableKDFChainModel own_key: PreStableKeyPairModel other_pub: PreStableKeyPairModel class PreStableSymmetricKeyRatchetModel(BaseModel): """ This model describes how symmetric-key ratchet instances were serialized in pre-stable serialization format. """ schain: Optional[PreStableKDFChainModel] rchain: Optional[PreStableKDFChainModel] prev_schain_length: Optional[int] class PreStableModel(BaseModel): """ This model describes how Double Ratchet instances were serialized in pre-stable serialization format. """ super: PreStableDiffieHellmanRatchetModel skr: PreStableSymmetricKeyRatchetModel ad: str # pylint: disable=invalid-name smks: Dict[str, str] def parse_diffie_hellman_ratchet_model(serialized: JSONObject) -> DiffieHellmanRatchetModel: """ Parse a serialized :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet` instance, as returned by :attr:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet.json`, into the most recent pydantic model available for the class. Perform migrations in case the pydantic models were updated. Args: serialized: The serialized instance. Returns: The model, which can be used to restore the instance using :meth:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet.from_model`. Note: Pre-stable data can only be migrated as a whole using :func:`parse_double_ratchet_model`. """ # Each model has a Python string "version" in its root. Use that to find the model that the data was # serialized from. version = cast(str, serialized["version"]) model: BaseModel = { "1.0.0": DiffieHellmanRatchetModel, "1.0.1": DiffieHellmanRatchetModel }[version](**serialized) # Once all migrations have been applied, the model should be an instance of the most recent model assert isinstance(model, DiffieHellmanRatchetModel) return model def parse_double_ratchet_model(serialized: JSONObject) -> DoubleRatchetModel: """ Parse a serialized :class:`~doubleratchet.double_ratchet.DoubleRatchet` instance, as returned by :attr:`~doubleratchet.double_ratchet.DoubleRatchet.json`, into the most recent pydantic model available for the class. Perform migrations in case the pydantic models were updated. Supports migration of pre-stable data. Args: serialized: The serialized instance. Returns: The model, which can be used to restore the instance using :meth:`~doubleratchet.double_ratchet.DoubleRatchet.from_model`. Raises: InconsistentSerializationException: if migration from pre-stable serialization format is performed, and the data is structurally correct, but incomplete. In pre-stable, it was possible to serialize instances which were not fully initialized yet. Those instances can be treated as non-existent and be replaced without losing information/messages. Note: The pre-stable serialization format left it up to the user to implement serialization of key pairs. The migration code assumes the format used by pre-stable `python-omemo `__ and will raise an exception if a different format was used. In that case, the custom format has to be migrated first by the user. """ # Each model has a Python string "version" in its root. Use that to find the model that the data was # serialized from. Special case: the pre-stable serialization format does not contain a version. version = cast(str, serialized["version"]) if "version" in serialized else None model: BaseModel = { None: PreStableModel, "1.0.0": DoubleRatchetModel, "1.0.1": DoubleRatchetModel }[version](**serialized) if isinstance(model, PreStableModel): # Run migrations from PreStableModel to DoubleRatchetModel if model.super.own_key.priv is None: raise InconsistentSerializationException( "The serialized data has no own ratchet private key set." ) if model.super.other_pub.pub is None: raise InconsistentSerializationException( "The serialized data has no recipient ratchet public key set." ) skipped_message_keys: List[SkippedMessageKeyModel] = [] for key, message_key in model.smks.items(): key_model = PreStableSMKKeyModel.parse_raw(key) skipped_message_keys.append(SkippedMessageKeyModel( ratchet_pub=base64.b64decode(key_model.pub), index=key_model.index, message_key=base64.b64decode(message_key) )) model = DoubleRatchetModel( diffie_hellman_ratchet=DiffieHellmanRatchetModel( own_ratchet_priv=base64.b64decode(model.super.own_key.priv), other_ratchet_pub=base64.b64decode(model.super.other_pub.pub), root_chain=KDFChainModel( length=model.super.root_chain.length, key=base64.b64decode(model.super.root_chain.key) ), symmetric_key_ratchet=SymmetricKeyRatchetModel( receiving_chain=None if model.skr.rchain is None else KDFChainModel( length=model.skr.rchain.length, key=base64.b64decode(model.skr.rchain.key) ), sending_chain=None if model.skr.schain is None else KDFChainModel( length=model.skr.schain.length, key=base64.b64decode(model.skr.schain.key) ), previous_sending_chain_length=model.skr.prev_schain_length ) ), skipped_message_keys=skipped_message_keys ) # Once all migrations have been applied, the model should be an instance of the most recent model assert isinstance(model, DoubleRatchetModel) return model def parse_kdf_chain_model(serialized: JSONObject) -> KDFChainModel: """ Parse a serialized :class:`~doubleratchet.kdf_chain.KDFChain` instance, as returned by :attr:`~doubleratchet.kdf_chain.KDFChain.json`, into the most recent pydantic model available for the class. Perform migrations in case the pydantic models were updated. Args: serialized: The serialized instance. Returns: The model, which can be used to restore the instance using :meth:`~doubleratchet.kdf_chain.KDFChain.from_model`. Note: Pre-stable data can only be migrated as a whole using :func:`parse_double_ratchet_model`. """ # Each model has a Python string "version" in its root. Use that to find the model that the data was # serialized from. version = cast(str, serialized["version"]) model: BaseModel = { "1.0.0": KDFChainModel, "1.0.1": KDFChainModel }[version](**serialized) # Once all migrations have been applied, the model should be an instance of the most recent model assert isinstance(model, KDFChainModel) return model def parse_symmetric_key_ratchet_model(serialized: JSONObject) -> SymmetricKeyRatchetModel: """ Parse a serialized :class:`~doubleratchet.symmetric_key_ratchet.SymmetricKeyRatchet` instance, as returned by :attr:`~doubleratchet.symmetric_key_ratchet.SymmetricKeyRatchet.json`, into the most recent pydantic model available for the class. Perform migrations in case the pydantic models were updated. Args: serialized: The serialized instance. Returns: The model, which can be used to restore the instance using :meth:`~doubleratchet.symmetric_key_ratchet.SymmetricKeyRatchet.from_model`. Note: Pre-stable data can only be migrated as a whole using :func:`parse_double_ratchet_model`. """ # Each model has a Python string "version" in its root. Use that to find the model that the data was # serialized from. version = cast(str, serialized["version"]) model: BaseModel = { "1.0.0": SymmetricKeyRatchetModel, "1.0.1": SymmetricKeyRatchetModel }[version](**serialized) # Once all migrations have been applied, the model should be an instance of the most recent model assert isinstance(model, SymmetricKeyRatchetModel) return model python-doubleratchet-1.0.3/doubleratchet/models.py000066400000000000000000000074131433244713400223530ustar00rootroot00000000000000from typing import Any, List, Optional from pydantic import BaseModel, validator __all__ = [ # pylint: disable=unused-variable "DiffieHellmanRatchetModel", "DoubleRatchetModel", "KDFChainModel", "SkippedMessageKeyModel", "SymmetricKeyRatchetModel" ] def _json_bytes_decoder(val: Any) -> bytes: """ Decode bytes from a string according to the JSON specification. See https://github.com/samuelcolvin/pydantic/issues/3756 for details. Args: val: The value to type check and decode. Returns: The value decoded to bytes. If the value is bytes already, it is returned unmodified. Raises: ValueError: if the value is not correctly encoded. """ if isinstance(val, bytes): return val if isinstance(val, str): return bytes(map(ord, val)) raise ValueError("bytes fields must be encoded as bytes or str.") def _json_bytes_encoder(val: bytes) -> str: """ Encode bytes as a string according to the JSON specification. See https://github.com/samuelcolvin/pydantic/issues/3756 for details. Args: val: The bytes to encode. Returns: The encoded bytes. """ return "".join(map(chr, val)) class KDFChainModel(BaseModel): """ The model representing the internal state of a :class:`~doubleratchet.kdf_chain.KDFChain`. """ version: str = "1.0.0" length: int key: bytes # Workaround for correct serialization of bytes, see :func:`bytes_decoder` above for details. class Config: # pylint: disable=missing-class-docstring json_encoders = { bytes: _json_bytes_encoder } _decoders = validator("key", pre=True, allow_reuse=True)(_json_bytes_decoder) class SymmetricKeyRatchetModel(BaseModel): """ The model representing the internal state of a :class:`~doubleratchet.symmetric_key_ratchet.SymmetricKeyRatchet`. """ version: str = "1.0.0" receiving_chain: Optional[KDFChainModel] sending_chain: Optional[KDFChainModel] previous_sending_chain_length: Optional[int] class DiffieHellmanRatchetModel(BaseModel): """ The model representing the internal state of a :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet`. """ version: str = "1.0.0" own_ratchet_priv: bytes other_ratchet_pub: bytes root_chain: KDFChainModel symmetric_key_ratchet: SymmetricKeyRatchetModel # Workaround for correct serialization of bytes, see :func:`bytes_decoder` above for details. class Config: # pylint: disable=missing-class-docstring json_encoders = { bytes: _json_bytes_encoder } _decoders = \ validator("own_ratchet_priv", "other_ratchet_pub", pre=True, allow_reuse=True)(_json_bytes_decoder) class SkippedMessageKeyModel(BaseModel): """ The model used as part of the :class:`DoubleRatchetModel`, representing a single skipped message key with meta data. """ ratchet_pub: bytes index: int message_key: bytes # Workaround for correct serialization of bytes, see :func:`bytes_decoder` above for details. class Config: # pylint: disable=missing-class-docstring json_encoders = { bytes: _json_bytes_encoder } _decoders = validator("ratchet_pub", "message_key", pre=True, allow_reuse=True)(_json_bytes_decoder) class DoubleRatchetModel(BaseModel): """ The model representing the internal state of a :class:`~doubleratchet.double_ratchet.DoubleRatchet`. """ version: str = "1.0.0" diffie_hellman_ratchet: DiffieHellmanRatchetModel skipped_message_keys: List[SkippedMessageKeyModel] # Workaround for correct serialization of bytes, see :func:`bytes_decoder` above for details. class Config: # pylint: disable=missing-class-docstring json_encoders = { bytes: _json_bytes_encoder } python-doubleratchet-1.0.3/doubleratchet/project.py000066400000000000000000000007031433244713400225310ustar00rootroot00000000000000__all__ = [ "project" ] # pylint: disable=unused-variable project = { "name" : "DoubleRatchet", "description" : "A Python implementation of the Double Ratchet algorithm.", "url" : "https://github.com/Syndace/python-doubleratchet", "year" : "2022", "author" : "Tim Henkes (Syndace)", "author_email" : "me@syndace.dev", "categories" : [ "Topic :: Security :: Cryptography" ] } python-doubleratchet-1.0.3/doubleratchet/py.typed000066400000000000000000000000001433244713400221760ustar00rootroot00000000000000python-doubleratchet-1.0.3/doubleratchet/recommended/000077500000000000000000000000001433244713400227735ustar00rootroot00000000000000python-doubleratchet-1.0.3/doubleratchet/recommended/__init__.py000066400000000000000000000003521433244713400251040ustar00rootroot00000000000000from .crypto_provider import HashFunction # Fun: # https://github.com/PyCQA/pylint/issues/6006 # https://github.com/python/mypy/issues/10198 __all__ = [ # pylint: disable=unused-variable # .crypto_provider "HashFunction" ] python-doubleratchet-1.0.3/doubleratchet/recommended/aead_aes_hmac.py000066400000000000000000000075021433244713400260630ustar00rootroot00000000000000from abc import abstractmethod from typing import Tuple from .crypto_provider import HashFunction from .crypto_provider_impl import CryptoProviderImpl from .. import aead __all__ = [ # pylint: disable=unused-variable "AEAD" ] class AEAD(aead.AEAD): """ An implementation of Authenticated Encryption with Associated Data using AES-256 in CBC mode, HKDF and HMAC with SHA-256 or SHA-512: HKDF is used with SHA-256 or SHA-512 to generate 80 bytes of output. The HKDF salt is set to a zero-filled byte sequence equal to the digest size of the hash function. HKDF input key material is set to AEAD key. HKDF info is set to an application-specific byte sequence distinct from other uses of HKDF in the application. The HKDF output is divided into a 32-byte encryption key, a 32-byte authentication key, and a 16-byte IV. The plaintext is encrypted using AES-256 in CBC mode with PKCS#7 padding, using the encryption key and IV from the previous step. HMAC is calculated using the authentication key and the same hash function as above. The HMAC input is the associated_data prepended to the ciphertext. The HMAC output is appended to the ciphertext. """ @staticmethod @abstractmethod def _get_hash_function() -> HashFunction: pass @staticmethod @abstractmethod def _get_info() -> bytes: pass @classmethod async def encrypt(cls, plaintext: bytes, key: bytes, associated_data: bytes) -> bytes: hash_function = cls._get_hash_function() encryption_key, authentication_key, iv = await cls.__derive(key, hash_function, cls._get_info()) # Encrypt the plaintext using AES-256 (the 256 bit are implied by the key size) in CBC mode and the # previously created key and IV, after padding it with PKCS#7 ciphertext = await CryptoProviderImpl.aes_cbc_encrypt(encryption_key, iv, plaintext) # Calculate the authentication tag auth = await CryptoProviderImpl.hmac_calculate( authentication_key, hash_function, associated_data + ciphertext ) # Append the authentication tag to the ciphertext return ciphertext + auth @classmethod async def decrypt(cls, ciphertext: bytes, key: bytes, associated_data: bytes) -> bytes: hash_function = cls._get_hash_function() decryption_key, authentication_key, iv = await cls.__derive(key, hash_function, cls._get_info()) # Split the authentication tag from the ciphertext auth = ciphertext[-hash_function.hash_size:] ciphertext = ciphertext[:-hash_function.hash_size] # Calculate and verify the authentication tag new_auth = await CryptoProviderImpl.hmac_calculate( authentication_key, hash_function, associated_data + ciphertext ) if new_auth != auth: raise aead.AuthenticationFailedException("Authentication tags do not match.") # Decrypt the plaintext using AES-256 (the 256 bit are implied by the key size) in CBC mode and the # previously created key and IV, and unpad the resulting plaintext with PKCS#7 return await CryptoProviderImpl.aes_cbc_decrypt(decryption_key, iv, ciphertext) @staticmethod async def __derive(key: bytes, hash_function: HashFunction, info: bytes) -> Tuple[bytes, bytes, bytes]: # Prepare the salt, a zero-filled byte sequence with the size of the hash digest salt = b"\x00" * hash_function.hash_size # Derive 80 bytes hkdf_out = await CryptoProviderImpl.hkdf_derive( hash_function=hash_function, length=80, salt=salt, info=info, key_material=key ) # Split these 80 bytes into three parts return hkdf_out[:32], hkdf_out[32:64], hkdf_out[64:] python-doubleratchet-1.0.3/doubleratchet/recommended/crypto_provider.py000066400000000000000000000065721433244713400266110ustar00rootroot00000000000000from abc import ABC, abstractmethod import enum from typing_extensions import assert_never __all__ = [ # pylint: disable=unused-variable "CryptoProvider", "HashFunction" ] @enum.unique class HashFunction(enum.Enum): """ Enumeration of the three hash functions that can be used with :class:`doubleratchet.recommended.aead_aes_hmac.AEAD`, :class:`doubleratchet.recommended.kdf_hkdf.KDF` and :class:`doubleratchet.recommended.kdf_separate_hmacs.KDF`. The three hash functions are SHA-256, SHA-512, and truncated SHA-512 to 256 bits. """ SHA_256: str = "SHA_256" SHA_512: str = "SHA_512" SHA_512_256: str = "SHA_512_256" @property def hash_size(self) -> int: """ Returns: The byte size of the hashes produced by this hash function. """ if self is HashFunction.SHA_256: return 32 if self is HashFunction.SHA_512: return 64 if self is HashFunction.SHA_512_256: return 32 return assert_never(self) class CryptoProvider(ABC): """ Abstraction of the cryptographic operations needed by this package to allow for different backend implementations. """ @staticmethod @abstractmethod async def hkdf_derive( hash_function: HashFunction, length: int, salt: bytes, info: bytes, key_material: bytes ) -> bytes: """ Args: hash_function: The hash function to parameterize the HKDF with. length: The number of bytes to derive. salt: The salt input for the HKDF. info: The info input for the HKDF. key_material: The input key material to derive from. Returns: The derived key material. """ @staticmethod @abstractmethod async def hmac_calculate(key: bytes, hash_function: HashFunction, data: bytes) -> bytes: """ Args: key: The authentication key. hash_function: The hash function to parameterize the HMAC with. data: The data to authenticate. Returns: The authentication tag. """ @staticmethod @abstractmethod async def aes_cbc_encrypt(key: bytes, initialization_vector: bytes, plaintext: bytes) -> bytes: """ Encrypt plaintext with AES-CBC. The plaintext is padded with PKCS#7 before encryption. Args: key: The AES key. Either 128, 192 or 256 bits. initialization_vector: The initialization vector as needed by AES-CBC. plaintext: The plaintext. Returns: The ciphertext obtained by padding the plaintext with PKCS#7 and then encrypting it with AES-CBC. """ @staticmethod @abstractmethod async def aes_cbc_decrypt(key: bytes, initialization_vector: bytes, ciphertext: bytes) -> bytes: """ Decrypt plaintext with AES-CBC. The plaintext is unpadded with PKCS#7 after decryption. Args: key: The AES key. Either 128, 192 or 256 bits. initialization_vector: The initialization vector as needed by AES-CBC. ciphertext: The ciphertext. Returns: The plaintext obtained by decrypting it with AES-CBC and unpadding the result with PKCS#7. Raises: DecryptionFailedException: on decryption or unpadding failure. """ python-doubleratchet-1.0.3/doubleratchet/recommended/crypto_provider_cryptography.py000066400000000000000000000065501433244713400314200ustar00rootroot00000000000000from typing_extensions import assert_never from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.hmac import HMAC from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.padding import PKCS7 from .crypto_provider import CryptoProvider, HashFunction from .. import aead __all__ = [ # pylint: disable=unused-variable "CryptoProviderImpl" ] def get_hash_algorithm(hash_function: HashFunction) -> hashes.HashAlgorithm: """ Args: hash_function: Identifier of a hash function. Returns: The implementation of the hash function as a cryptography :class:`~cryptography.hazmat.primitives.hashes.HashAlgorithm` object. """ if hash_function is HashFunction.SHA_256: return hashes.SHA256() if hash_function is HashFunction.SHA_512: return hashes.SHA512() if hash_function is HashFunction.SHA_512_256: return hashes.SHA512_256() return assert_never(hash_function) class CryptoProviderImpl(CryptoProvider): """ Cryptography provider based on the Python package `cryptography `_. """ @staticmethod async def hkdf_derive( hash_function: HashFunction, length: int, salt: bytes, info: bytes, key_material: bytes ) -> bytes: return HKDF( algorithm=get_hash_algorithm(hash_function), length=length, salt=salt, info=info, backend=default_backend() ).derive(key_material) @staticmethod async def hmac_calculate(key: bytes, hash_function: HashFunction, data: bytes) -> bytes: hmac = HMAC(key, get_hash_algorithm(hash_function), backend=default_backend()) hmac.update(data) return hmac.finalize() @staticmethod async def aes_cbc_encrypt(key: bytes, initialization_vector: bytes, plaintext: bytes) -> bytes: # Prepare PKCS#7 padded plaintext padder = PKCS7(128).padder() padded_plaintext = padder.update(plaintext) + padder.finalize() # Encrypt the plaintext using AES-CBC aes = Cipher( algorithms.AES(key), modes.CBC(initialization_vector), backend=default_backend() ).encryptor() return aes.update(padded_plaintext) + aes.finalize() # pylint: disable=no-member @staticmethod async def aes_cbc_decrypt(key: bytes, initialization_vector: bytes, ciphertext: bytes) -> bytes: # Decrypt the plaintext using AES-CBC try: aes = Cipher( algorithms.AES(key), modes.CBC(initialization_vector), backend=default_backend() ).decryptor() padded_plaintext = aes.update(ciphertext) + aes.finalize() # pylint: disable=no-member except ValueError as e: raise aead.DecryptionFailedException("Decryption failed.") from e # Remove the PKCS#7 padding from the plaintext try: unpadder = PKCS7(128).unpadder() return unpadder.update(padded_plaintext) + unpadder.finalize() except ValueError as e: raise aead.DecryptionFailedException("Plaintext padded incorrectly.") from e python-doubleratchet-1.0.3/doubleratchet/recommended/crypto_provider_impl.py000066400000000000000000000002111433244713400276120ustar00rootroot00000000000000from .crypto_provider_cryptography import CryptoProviderImpl __all__ = [ # pylint: disable=unused-variable "CryptoProviderImpl" ] python-doubleratchet-1.0.3/doubleratchet/recommended/diffie_hellman_ratchet_curve25519.py000066400000000000000000000025641433244713400316260ustar00rootroot00000000000000from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey from .. import diffie_hellman_ratchet __all__ = [ # pylint: disable=unused-variable "DiffieHellmanRatchet" ] class DiffieHellmanRatchet(diffie_hellman_ratchet.DiffieHellmanRatchet): """ An implementation of :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet` using Curve25519 keys and performing X25519 key exchanges. Implementation relies on the Python package `cryptography `_. """ @staticmethod def _generate_priv() -> bytes: return X25519PrivateKey.generate().private_bytes( encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption() ) @staticmethod def _derive_pub(priv: bytes) -> bytes: return X25519PrivateKey.from_private_bytes(priv).public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) @staticmethod def _perform_diffie_hellman(own_priv: bytes, other_pub: bytes) -> bytes: return X25519PrivateKey.from_private_bytes(own_priv).exchange(X25519PublicKey.from_public_bytes( other_pub )) python-doubleratchet-1.0.3/doubleratchet/recommended/diffie_hellman_ratchet_curve448.py000066400000000000000000000025421433244713400314540ustar00rootroot00000000000000from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.x448 import X448PrivateKey, X448PublicKey from .. import diffie_hellman_ratchet __all__ = [ # pylint: disable=unused-variable "DiffieHellmanRatchet" ] class DiffieHellmanRatchet(diffie_hellman_ratchet.DiffieHellmanRatchet): """ An implementation of :class:`~doubleratchet.diffie_hellman_ratchet.DiffieHellmanRatchet` using Curve448 keys and performing X448 key exchanges. Implementation relies on the Python package `cryptography `_. """ @staticmethod def _generate_priv() -> bytes: return X448PrivateKey.generate().private_bytes( encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption() ) @staticmethod def _derive_pub(priv: bytes) -> bytes: return X448PrivateKey.from_private_bytes(priv).public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) @staticmethod def _perform_diffie_hellman(own_priv: bytes, other_pub: bytes) -> bytes: return X448PrivateKey.from_private_bytes(own_priv).exchange(X448PublicKey.from_public_bytes( other_pub )) python-doubleratchet-1.0.3/doubleratchet/recommended/kdf_hkdf.py000066400000000000000000000021521433244713400251050ustar00rootroot00000000000000from abc import abstractmethod from .crypto_provider import HashFunction from .crypto_provider_impl import CryptoProviderImpl from .. import kdf __all__ = [ # pylint: disable=unused-variable "KDF" ] class KDF(kdf.KDF): """ This KDF implemention uses HKDF with SHA-256 or SHA-512, using the KDF key as HKDF salt, the KDF data as HKDF input key material, and an application-specific byte sequence as HKDF info. The info value should be chosen to be distinct from other uses of HKDF in the application. https://signal.org/docs/specifications/doubleratchet/#recommended-cryptographic-algorithms """ @staticmethod @abstractmethod def _get_hash_function() -> HashFunction: pass @staticmethod @abstractmethod def _get_info() -> bytes: pass @classmethod async def derive(cls, key: bytes, data: bytes, length: int) -> bytes: return await CryptoProviderImpl.hkdf_derive( hash_function=cls._get_hash_function(), length=length, salt=key, info=cls._get_info(), key_material=data ) python-doubleratchet-1.0.3/doubleratchet/recommended/kdf_separate_hmacs.py000066400000000000000000000032701433244713400271520ustar00rootroot00000000000000from abc import abstractmethod from .crypto_provider import HashFunction from .crypto_provider_impl import CryptoProviderImpl from .. import kdf __all__ = [ # pylint: disable=unused-variable "KDF" ] class KDF(kdf.KDF): """ This implementation uses HMAC with SHA-256 or SHA-512 to derive multiple outputs from a single KDF key. These outputs are concatenated and returned as a whole. The KDF key is used as the HMAC key, the KDF data is split into single bytes and one HMAC is calculated for each byte. For example, passing ``b"\\x01\\x02"`` as the KDF data results in two HMACs being calculated, one using ``b"\\x01"`` as the HMAC input and the other using ``b"\\x02"``. The two HMAC outputs are concatenated and returned. Note that the length of the output is fixed to a multiple of the HMAC digest size, based on the length of the KDF data. https://signal.org/docs/specifications/doubleratchet/#recommended-cryptographic-algorithms """ @staticmethod @abstractmethod def _get_hash_function() -> HashFunction: pass @classmethod async def derive(cls, key: bytes, data: bytes, length: int) -> bytes: hash_function = cls._get_hash_function() if length != len(data) * hash_function.hash_size: raise ValueError( "This HMAC-based KDF implementation can only derive keys that are n times as big as the byte" " size of the hash function digest, where n is the number of bytes in the KDF data." ) result = b"" for i in range(len(data)): result += await CryptoProviderImpl.hmac_calculate(key, hash_function, data[i:i + 1]) return result python-doubleratchet-1.0.3/doubleratchet/symmetric_key_ratchet.py000066400000000000000000000202431433244713400254620ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable import enum import json from typing import Optional, Type, TypeVar, cast from typing_extensions import assert_never from .kdf import KDF from .kdf_chain import KDFChain from .migrations import parse_symmetric_key_ratchet_model from .models import SymmetricKeyRatchetModel from .types import JSONObject __all__ = [ # pylint: disable=unused-variable "Chain", "ChainNotAvailableException", "SymmetricKeyRatchet" ] class ChainNotAvailableException(Exception): """ Raised by :meth:`SymmetricKeyRatchet.next_encryption_key` and :meth:`SymmetricKeyRatchet.next_decryption_key` in case the required chain has not been initialized yet. """ @enum.unique class Chain(enum.Enum): """ Enumeration identifying the chain to replace by :meth:`SymmetricKeyRatchet.replace_chain`. """ SENDING: str = "SENDING" RECEIVING: str = "RECEIVING" SymmetricKeyRatchetTypeT = TypeVar("SymmetricKeyRatchetTypeT", bound="SymmetricKeyRatchet") class SymmetricKeyRatchet: """ The sending and receiving chains advance as each message is sent and received. Their output keys are used to encrypt and decrypt messages. This is called the symmetric-key ratchet. https://signal.org/docs/specifications/doubleratchet/#symmetric-key-ratchet """ def __init__(self) -> None: # Just the type definitions here self.__kdf: Type[KDF] self.__constant: bytes self.__receiving_chain: Optional[KDFChain] self.__sending_chain: Optional[KDFChain] self.__previous_sending_chain_length: Optional[int] @classmethod def create( cls: Type[SymmetricKeyRatchetTypeT], chain_kdf: Type[KDF], constant: bytes ) -> SymmetricKeyRatchetTypeT: """ Args: chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. constant: The constant to feed into the sending and receiving KDF chains on each step. Returns: A configured instance of :class:`SymmetricKeyRatchet`. """ self = cls() self.__kdf = chain_kdf self.__constant = constant self.__receiving_chain = None self.__sending_chain = None self.__previous_sending_chain_length = None return self @property def model(self) -> SymmetricKeyRatchetModel: """ Returns: The internal state of this :class:`SymmetricKeyRatchet` as a pydantic model. """ return SymmetricKeyRatchetModel( receiving_chain=None if self.__receiving_chain is None else self.__receiving_chain.model, sending_chain=None if self.__sending_chain is None else self.__sending_chain.model, previous_sending_chain_length=self.__previous_sending_chain_length ) @property def json(self) -> JSONObject: """ Returns: The internal state of this :class:`SymmetricKeyRatchet` as a JSON-serializable Python object. """ return cast(JSONObject, json.loads(self.model.json())) @classmethod def from_model( cls: Type[SymmetricKeyRatchetTypeT], model: SymmetricKeyRatchetModel, chain_kdf: Type[KDF], constant: bytes ) -> SymmetricKeyRatchetTypeT: """ Args: model: The pydantic model holding the internal state of a :class:`SymmetricKeyRatchet`, as produced by :attr:`model`. chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. constant: The constant to feed into the sending and receiving KDF chains on each step. Returns: A configured instance of :class:`SymmetricKeyRatchet`, with internal state restored from the model. Warning: Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the documentation for details. """ self = cls() self.__kdf = chain_kdf self.__constant = constant self.__receiving_chain = None if model.receiving_chain is None else KDFChain.from_model( model.receiving_chain, chain_kdf ) self.__sending_chain = None if model.sending_chain is None else KDFChain.from_model( model.sending_chain, chain_kdf ) self.__previous_sending_chain_length = model.previous_sending_chain_length return self @classmethod def from_json( cls: Type[SymmetricKeyRatchetTypeT], serialized: JSONObject, chain_kdf: Type[KDF], constant: bytes ) -> SymmetricKeyRatchetTypeT: """ Args: serialized: A JSON-serializable Python object holding the internal state of a :class:`SymmetricKeyRatchet`, as produced by :attr:`json`. chain_kdf: The KDF to use for the sending and receiving chains. The KDF must be capable of deriving 64 bytes. constant: The constant to feed into the sending and receiving KDF chains on each step. Returns: A configured instance of :class:`SymmetricKeyRatchet`, with internal state restored from the serialized data. """ return cls.from_model( parse_symmetric_key_ratchet_model(serialized), chain_kdf, constant ) def replace_chain(self, chain: Chain, key: bytes) -> None: """ Replace either the sending or the receiving chain with a new KDF chain. Args: chain: The chain to replace. key: The initial chain key for the new KDF chain. """ if len(key) != 32: raise ValueError("The chain key must consist of 32 bytes.") if chain is Chain.SENDING: self.__previous_sending_chain_length = self.sending_chain_length self.__sending_chain = KDFChain.create(self.__kdf, key) elif chain is Chain.RECEIVING: self.__receiving_chain = KDFChain.create(self.__kdf, key) else: assert_never(chain) @property def previous_sending_chain_length(self) -> Optional[int]: """ Returns: The length of the previous sending chain, if it exists. """ return self.__previous_sending_chain_length @property def sending_chain_length(self) -> Optional[int]: """ Returns: The length of the sending chain, if it exists. """ return None if self.__sending_chain is None else self.__sending_chain.length @property def receiving_chain_length(self) -> Optional[int]: """ Returns: The length of the receiving chain, if it exists. """ return None if self.__receiving_chain is None else self.__receiving_chain.length async def next_encryption_key(self) -> bytes: """ Returns: The next (32 bytes) encryption key derived from the sending chain. Raises: ChainNotAvailableException: if the sending chain was never initialized. """ if self.__sending_chain is None: raise ChainNotAvailableException( "The sending chain was never initialized, can not derive the next encryption key." ) return await self.__sending_chain.step(self.__constant, 32) async def next_decryption_key(self) -> bytes: """ Returns: The next (32 bytes) decryption key derived from the receiving chain. Raises: ChainNotAvailableException: if the receiving chain was never initialized. """ if self.__receiving_chain is None: raise ChainNotAvailableException( "The receiving chain was never initialized, can not derive the next decryption key." ) return await self.__receiving_chain.step(self.__constant, 32) python-doubleratchet-1.0.3/doubleratchet/types.py000066400000000000000000000034471433244713400222370ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable from typing import List, Mapping, NamedTuple, OrderedDict, Tuple, Union __all__ = [ # pylint: disable=unused-variable "EncryptedMessage", "Header", "JSONObject", "SkippedMessageKeys" ] ################ # Type Aliases # ################ # # Thanks @vanburgerberg - https://github.com/python/typing/issues/182 # if TYPE_CHECKING: # class JSONArray(list[JSONType], Protocol): # type: ignore # __class__: Type[list[JSONType]] # type: ignore # # class JSONObject(dict[str, JSONType], Protocol): # type: ignore # __class__: Type[dict[str, JSONType]] # type: ignore # # JSONType = Union[None, float, int, str, bool, JSONArray, JSONObject] # Sadly @vanburgerberg's solution doesn't seem to like Dict[str, bool], thus for now an incomplete JSON # type with finite levels of depth. Primitives = Union[None, float, int, str, bool] JSONType1 = Union[Primitives, List[Primitives], Mapping[str, Primitives]] JSONType = Union[Primitives, List[JSONType1], Mapping[str, JSONType1]] JSONObject = Mapping[str, JSONType] SkippedMessageKeys = OrderedDict[Tuple[bytes, int], bytes] ############################ # Structures (NamedTuples) # ############################ class Header(NamedTuple): """ The header structure sent with each Double Ratchet-encrypted message, containing the metadata to keep the ratchets synchronized. """ ratchet_pub: bytes previous_sending_chain_length: int sending_chain_length: int class EncryptedMessage(NamedTuple): """ A Double Ratchet-encrypted message, consisting of the header and ciphertext. """ header: Header ciphertext: bytes python-doubleratchet-1.0.3/doubleratchet/version.py000066400000000000000000000003231433244713400225460ustar00rootroot00000000000000__all__ = [ "__version__" ] # pylint: disable=unused-variable __version__ = {} __version__["short"] = "1.0.3" __version__["tag"] = "stable" __version__["full"] = f"{__version__['short']}-{__version__['tag']}" python-doubleratchet-1.0.3/examples/000077500000000000000000000000001433244713400175025ustar00rootroot00000000000000python-doubleratchet-1.0.3/examples/dr_chat.py000066400000000000000000000246551433244713400214740ustar00rootroot00000000000000import argparse import asyncio import json import os import pickle import shutil import time import traceback from typing import Any, Dict, List, Optional, Tuple from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.x448 import X448PrivateKey from doubleratchet import DoubleRatchet as DR, EncryptedMessage, Header from doubleratchet.recommended import ( aead_aes_hmac, diffie_hellman_ratchet_curve448 as dhr448, HashFunction, kdf_hkdf, kdf_separate_hmacs ) class DoubleRatchet(DR): """ An example of a Double Ratchet implementation used in the chat. """ @staticmethod def _build_associated_data(associated_data: bytes, header: Header) -> bytes: return ( associated_data + header.ratchet_pub + header.sending_chain_length.to_bytes(8, "big") + header.previous_sending_chain_length.to_bytes(8, "big") ) class DiffieHellmanRatchet(dhr448.DiffieHellmanRatchet): """ Use the recommended X448-based Diffie-Hellman ratchet implementation in this example. """ class AEAD(aead_aes_hmac.AEAD): """ Use the recommended AES/HMAC-based AEAD implementation in this example, with SHA-512 and a fitting info string. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_512 @staticmethod def _get_info() -> bytes: return "Double Ratchet Chat AEAD".encode("ASCII") class RootChainKDF(kdf_hkdf.KDF): """ Use the recommended HKDF-based KDF implementation for the root chain in this example, with SHA-512 and a fitting info string. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_512 @staticmethod def _get_info() -> bytes: return "Double Ratchet Chat Root Chain KDF".encode("ASCII") class MessageChainKDF(kdf_separate_hmacs.KDF): """ Use the recommended separate HMAC-based KDF implementation for the message chain in this example, with truncated SHA-512. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_512_256 # Configuration of the DoubleRatchet class, which has to be passed to each constructing method # (encrypt_initial_message, decrypt_initial_message, deserialize). dr_configuration: Dict[str, Any] = { "diffie_hellman_ratchet_class": DiffieHellmanRatchet, "root_chain_kdf": RootChainKDF, "message_chain_kdf": MessageChainKDF, "message_chain_constant": b"\x01\x02", "dos_protection_threshold": 100, "max_num_skipped_message_keys": 1000, "aead": AEAD } # Prepare the associated data, which is application-defined. ad = "Alice + Bob".encode("ASCII") shared_secret = "**32 bytes of very secret data**".encode("ASCII") async def create_double_ratchets(message: bytes) -> Tuple[DoubleRatchet, DoubleRatchet]: """ Create the Double Ratchets for Alice and Bob by encrypting/decrypting an initial message. Args: message: The initial message. Returns: The Double Ratchets of Alice and Bob. """ # In a real application, the key exchange that also yields the shared secret for the session initiation # probably manages the ratchet key pair. bob_ratchet_priv = X448PrivateKey.generate() bob_ratchet_pub = bob_ratchet_priv.public_key() # Create Alice' Double Ratchet by encrypting the initial message for Bob: alice_dr, initial_message_encrypted = await DoubleRatchet.encrypt_initial_message( shared_secret=shared_secret, recipient_ratchet_pub=bob_ratchet_pub.public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw ), message=message, associated_data=ad, **dr_configuration ) print(f"Alice> {message.decode('UTF-8')}") # Create Bobs' Double Ratchet by decrypting the initial message from Alice: bob_dr, initial_message_decrypted = await DoubleRatchet.decrypt_initial_message( shared_secret=shared_secret, own_ratchet_priv=bob_ratchet_priv.private_bytes( encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption() ), message=initial_message_encrypted, associated_data=ad, **dr_configuration ) print(f"Bob< {initial_message_decrypted.decode('UTF-8')}") # Bob should have decrypted the message Alice sent him assert message == initial_message_decrypted return alice_dr, bob_dr Deferred = Dict[str, List[EncryptedMessage]] async def loop(alice_dr: DoubleRatchet, bob_dr: DoubleRatchet, deferred: Deferred) -> bool: """ The loop logic of this chat example. Args: alice_dr: The Double Ratchet of Alice. bob_dr: The Double Ratchet of Bob. deferred: The dictionary to hold deferred messages. Returns: Whether to quit the chat. """ print("a: Send a message from Alice to Bob") print("b: Send a message from Bob to Alice") print("da: Send a deferred message from Alice to Bob") print("db: Send a deferred message from Bob to Alice") print("q: Quit") action = input("Action: ") if action == "a": sender = "Alice" receiver = "Bob" sender_dr = alice_dr receiver_dr = bob_dr if action == "b": sender = "Bob" receiver = "Alice" sender_dr = bob_dr receiver_dr = alice_dr if action in [ "a", "b" ]: # Ask for the message to send message = input(f"{sender}> ") # Encrypt the message for the receiver message_encrypted = await sender_dr.encrypt_message(message.encode("UTF-8"), ad) while True: send_or_defer = input("Send the message or save it for later? (s or d): ") if send_or_defer in ["s", "d"]: break if send_or_defer == "s": # Now the receiver can decrypt the message message_decrypted = await receiver_dr.decrypt_message(message_encrypted, ad) print(f"{receiver}< {message_decrypted.decode('UTF-8')}") if send_or_defer == "d": deferred[sender].append(message_encrypted) print("(message saved)") if action == "da": sender = "Alice" receiver = "Bob" receiver_dr = bob_dr if action == "db": sender = "Bob" receiver = "Alice" receiver_dr = alice_dr if action in [ "da", "db" ]: num_saved_messages = len(deferred[sender]) if num_saved_messages == 0: print(f"No messages saved from {sender} to {receiver}.") else: while True: message_index = int(input( f"{num_saved_messages} messages saved. Index of the message to send: " )) if 0 <= message_index < num_saved_messages: break message_encrypted = deferred[sender][message_index] del deferred[sender][message_index] # Now the receiver can decrypt the message message_decrypted = await receiver_dr.decrypt_message(message_encrypted, ad) print(f"{receiver}< {message_decrypted.decode('UTF-8')}") return action != "q" async def main_loop(alice_dr: DoubleRatchet, bob_dr: DoubleRatchet, deferred: Deferred) -> None: """ The main loop of this chat example. Args: alice_dr: The Double Ratchet of Alice. bob_dr: The Double Ratchet of Bob. deferred: The dictionary to hold deferred messages. """ while True: try: if not await loop(alice_dr, bob_dr, deferred): break except BaseException: # pylint: disable=broad-except print("Exception raised while processing:") traceback.print_exc() time.sleep(0.5) print("") print("") async def main() -> None: """ The entry point for this chat example. Parses command line args, loads cached data, runs the mainloop and caches data before quitting. """ # https://github.com/PyCQA/pylint/issues/3942 # pylint: disable=no-member parser = argparse.ArgumentParser(description="Double Ratchet Chat") parser.add_argument("-i", "--ignore-cache", dest="ignore_cache", action="store_true", help="ignore the cache completely, neither loading data from the cache nor storing" " data into the cache") parser.add_argument("-c", "--clear-cache", dest="clear_cache", action="store_true", help="clear the cache and quit") args = parser.parse_args() storage_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "dr_chat_storage") if args.clear_cache: shutil.rmtree(storage_dir) return if not args.ignore_cache: try: os.mkdir(storage_dir) except FileExistsError: pass alice_dr: Optional[DoubleRatchet] = None bob_dr: Optional[DoubleRatchet] = None deferred: Optional[Deferred] = None if not args.ignore_cache: try: with open(os.path.join(storage_dir, "alice_dr.json"), "r", encoding="utf-8") as alice_dr_json: alice_dr = DoubleRatchet.from_json(json.load(alice_dr_json), **dr_configuration) with open(os.path.join(storage_dir, "bob_dr.json"), "r", encoding="utf-8") as bob_dr_json: bob_dr = DoubleRatchet.from_json(json.load(bob_dr_json), **dr_configuration) with open(os.path.join(storage_dir, "deferred.pickle"), "rb") as deferred_bin: deferred = pickle.load(deferred_bin) except OSError: pass if alice_dr is None or bob_dr is None or deferred is None: alice_dr, bob_dr = await create_double_ratchets("(initial message)".encode("UTF-8")) deferred = { "Alice": [], "Bob": [] } await main_loop(alice_dr, bob_dr, deferred) if not args.ignore_cache: with open(os.path.join(storage_dir, "alice_dr.json"), "w", encoding="utf-8") as alice_dr_json: json.dump(alice_dr.json, alice_dr_json) with open(os.path.join(storage_dir, "bob_dr.json"), "w", encoding="utf-8") as bob_dr_json: json.dump(bob_dr.json, bob_dr_json) with open(os.path.join(storage_dir, "deferred.pickle"), "wb") as deferred_bin: pickle.dump(deferred, deferred_bin) if __name__ == "__main__": asyncio.run(main()) python-doubleratchet-1.0.3/pylintrc000066400000000000000000000363521433244713400174640ustar00rootroot00000000000000[MASTER] # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code. extension-pkg-whitelist=pydantic # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. jobs=1 # Control the amount of potential inferred values when inferring a single # object. This can help the performance when dealing with large functions or # complex, nested conditions. limit-inference-results=100 # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins= # Pickle collected data for later comparisons. persistent=yes # Specify a configuration file. #rcfile= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. confidence= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once). You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". disable=missing-module-docstring, duplicate-code, fixme, logging-fstring-interpolation # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable=useless-suppression [REPORTS] # Python expression which should return a score less than or equal to 10. You # have access to the variables 'error', 'warning', 'refactor', and 'convention' # which contain the number of messages in each category, as well as 'statement' # which is the total number of statements analyzed. This score is used by the # global evaluation report (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details. #msg-template= # Set the output format. Available formats are text, parseable, colorized, json # and msvs (visual studio). You can also give a reporter class, e.g. # mypackage.mymodule.MyReporterClass. output-format=text # Tells whether to display a full report or only the messages. reports=no # Activate the evaluation score. score=yes [REFACTORING] # Maximum number of nested blocks for function / method body max-nested-blocks=5 # Complete name of functions that never returns. When checking for # inconsistent-return-statements if a never returning function is called then # it will be considered as an explicit return statement and no message will be # printed. never-returning-functions=sys.exit [SPELLING] # Limits count of emitted suggestions for spelling mistakes. max-spelling-suggestions=4 # Spelling dictionary name. Available dictionaries: none. To make it work, # install the python-enchant package. spelling-dict= # List of comma separated words that should not be checked. spelling-ignore-words= # A path to a file that contains the private dictionary; one word per line. spelling-private-dict-file= # Tells whether to store unknown words to the private dictionary (see the # --spelling-private-dict-file option) instead of raising a message. spelling-store-unknown-words=no [VARIABLES] # List of additional names supposed to be defined in builtins. Remember that # you should avoid defining new builtins when possible. additional-builtins= # Tells whether unused global variables should be treated as a violation. allow-global-unused-variables=no # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks=cb_, _cb # A regular expression matching the name of dummy variables (i.e. expected to # not be used). dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ # Argument names that match this expression will be ignored. Default to name # with leading underscore. ignored-argument-names=_.*|^ignored_|^unused_ # Tells whether we should check for unused import in __init__ files. init-import=no # List of qualified module names which can have objects that can redefine # builtins. redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io [LOGGING] # Format style used to check logging format string. `old` means using % # formatting, `new` is for `{}` formatting,and `fstr` is for f-strings. logging-format-style=old # Logging modules to check that the string format arguments are in logging # function parameter format. logging-modules=logging [FORMAT] # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' # Maximum number of characters on a single line. max-line-length=110 # Maximum number of lines in a module. max-module-lines=10000 # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=yes [TYPECHECK] # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that # produce valid context managers. contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members= # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # Tells whether to warn about missing members when the owner of the attribute # is inferred to be None. ignore-none=no # This flag controls whether pylint should warn about no-member and similar # checks whenever an opaque object is returned when inferring. The inference # can return multiple potential results while evaluating a Python object, but # some branches might not be evaluated, which results in partial inference. In # that case, it might be useful to still emit no-member and other checks for # the rest of the inferred objects. ignore-on-opaque-inference=no # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes=optparse.Values,thread._local,_thread._local # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis). It # supports qualified module names, as well as Unix pattern matching. ignored-modules= # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. missing-member-hint=yes # The minimum edit distance a name should have in order to be considered a # similar match for a missing member name. missing-member-hint-distance=1 # The total number of similar names that should be taken in consideration when # showing a hint for a missing member. missing-member-max-choices=1 # List of decorators that change the signature of a decorated function. signature-mutators= [STRING] # This flag controls whether the implicit-str-concat-in-sequence should # generate a warning on implicit string concatenation in sequences defined over # several lines. check-str-concat-over-line-jumps=no [SIMILARITIES] # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes # Ignore imports when computing similarities. ignore-imports=no # Minimum lines number of a similarity. min-similarity-lines=4 [BASIC] # Naming style matching correct argument names. argument-naming-style=snake_case # Regular expression matching correct argument names. Overrides argument- # naming-style. #argument-rgx= # Naming style matching correct attribute names. attr-naming-style=snake_case # Regular expression matching correct attribute names. Overrides attr-naming- # style. #attr-rgx= # Bad variable names which should always be refused, separated by a comma. bad-names=foo, bar, baz, toto, tutu, tata # Naming style matching correct class attribute names. class-attribute-naming-style=snake_case # Regular expression matching correct class attribute names. Overrides class- # attribute-naming-style. #class-attribute-rgx= # Naming style matching correct class names. class-naming-style=PascalCase # Regular expression matching correct class names. Overrides class-naming- # style. #class-rgx= # Naming style matching correct constant names. const-naming-style=any # Regular expression matching correct constant names. Overrides const-naming- # style. #const-rgx= # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 # Naming style matching correct function names. function-naming-style=snake_case # Regular expression matching correct function names. Overrides function- # naming-style. #function-rgx= # Good variable names which should always be accepted, separated by a comma. good-names=i, j, e, # exceptions in except blocks _, iv # Domain-specific two-letter variable names # Include a hint for the correct naming format with invalid-name. include-naming-hint=no # Naming style matching correct inline iteration names. inlinevar-naming-style=any # Regular expression matching correct inline iteration names. Overrides # inlinevar-naming-style. #inlinevar-rgx= # Naming style matching correct method names. method-naming-style=snake_case # Regular expression matching correct method names. Overrides method-naming- # style. #method-rgx= # Naming style matching correct module names. module-naming-style=snake_case # Regular expression matching correct module names. Overrides module-naming- # style. #module-rgx= # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=^_ # List of decorators that produce properties, such as abc.abstractproperty. Add # to this list to register other decorators that produce valid properties. # These decorators are taken in consideration only for invalid-name. property-classes=abc.abstractproperty # Naming style matching correct variable names. variable-naming-style=snake_case # Regular expression matching correct variable names. Overrides variable- # naming-style. #variable-rgx= [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME, XXX, TODO [IMPORTS] # List of modules that can be imported at any level, not just the top level # one. allow-any-import-level= # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists # only in one or another interpreter, leading to false positives when analysed. analyse-fallback-blocks=yes # Deprecated modules which should not be used, separated by a comma. deprecated-modules=optparse,tkinter.tix # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled). ext-import-graph= # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled). import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled). int-import-graph= # Force import order to recognize a module as part of the standard # compatibility libraries. known-standard-library= # Force import order to recognize a module as part of a third party library. known-third-party=enchant # Couples of modules and preferred modules, separated by a comma. preferred-modules= [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__, __new__, setUp, __post_init__, create # List of member names, which should be excluded from the protected access # warning. exclude-protected=_asdict, _fields, _replace, _source, _make # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=cls [DESIGN] # Maximum number of arguments for function / method. max-args=100 # Maximum number of attributes for a class (see R0902). max-attributes=100 # Maximum number of boolean expressions in an if statement (see R0916). max-bool-expr=10 # Maximum number of branch for function / method body. max-branches=100 # Maximum number of locals for function / method body. max-locals=100 # Maximum number of parents for a class (see R0901). max-parents=10 # Maximum number of public methods for a class (see R0904). max-public-methods=100 # Maximum number of return / yield for function / method body. max-returns=100 # Maximum number of statements in function / method body. max-statements=1000 # Minimum number of public methods for a class (see R0903). min-public-methods=0 [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to # "BaseException, Exception". overgeneral-exceptions=BaseException, Exception python-doubleratchet-1.0.3/pyproject.toml000066400000000000000000000001211433244713400205720ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" python-doubleratchet-1.0.3/requirements.txt000066400000000000000000000000751433244713400211520ustar00rootroot00000000000000cryptography>=3.3.2 pydantic>=1.7.4 typing-extensions>=4.3.0 python-doubleratchet-1.0.3/setup.py000066400000000000000000000040461433244713400174020ustar00rootroot00000000000000# pylint: disable=exec-used import os from typing import Dict, Union, List from setuptools import setup, find_packages # type: ignore[import] source_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "doubleratchet") version_scope: Dict[str, Dict[str, str]] = {} with open(os.path.join(source_root, "version.py"), encoding="utf-8") as f: exec(f.read(), version_scope) version = version_scope["__version__"] project_scope: Dict[str, Dict[str, Union[str, List[str]]]] = {} with open(os.path.join(source_root, "project.py"), encoding="utf-8") as f: exec(f.read(), project_scope) project = project_scope["project"] with open("README.md", encoding="utf-8") as f: long_description = f.read() classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy" ] classifiers.extend(project["categories"]) if version["tag"] == "alpha": classifiers.append("Development Status :: 3 - Alpha") if version["tag"] == "beta": classifiers.append("Development Status :: 4 - Beta") if version["tag"] == "stable": classifiers.append("Development Status :: 5 - Production/Stable") del project["categories"] del project["year"] setup( version=version["short"], long_description=long_description, long_description_content_type="text/markdown", license="MIT", packages=find_packages(exclude=["tests"]), install_requires=[ "cryptography>=3.3.2", "pydantic>=1.7.4", "typing-extensions>=4.3.0" ], python_requires=">=3.7", include_package_data=True, zip_safe=False, classifiers=classifiers, **project ) python-doubleratchet-1.0.3/tests/000077500000000000000000000000001433244713400170265ustar00rootroot00000000000000python-doubleratchet-1.0.3/tests/__init__.py000066400000000000000000000000401433244713400211310ustar00rootroot00000000000000# To make relative imports work python-doubleratchet-1.0.3/tests/migration_data/000077500000000000000000000000001433244713400220105ustar00rootroot00000000000000python-doubleratchet-1.0.3/tests/migration_data/alice-skipped-messages-pre-stable.json000066400000000000000000000036501433244713400312620ustar00rootroot00000000000000[[{"header": {"ratchet_pub": "Gy+CL40NFO7uiWO6OOnbxUU97Z2lKx5fO+6Vc9hvO0M=", "n": 1, "pn": 1}, "ciphertext": "h9UNMmB5MhBdQF4uC3m25ee481gu9RHgbYWMsXPWKWylIsmjOUV7gA6IHuDKJYfy+cMouaS/nD+xW/w8P8QZ8P0D1w29jtW/khnifM6jVwVllbgjJdWBZb4VcGuiEu3fRUogHH9auPKzUXpJcEYh5KfoU175M+dwqQFZSDvFyZ1M2m6kZPXr1riwnYH5CLqQtc/GmGyyCkQCWCNPHXwJPFUhVutYSUWwcN7eiSSzwQk="}, "0yId5rlRbCFOQz1ycMcmFcs2E5ovmCdsbce7QLODuqrtUK/9o1E/cfqUpv6i0KxTzOzInUh6QqAOhPTvHrV28UZo86I9A5VsdiMqyq69mDlg5Ww6y1aSjsEUNX5RHDPWYRtp2A=="], [{"header": {"ratchet_pub": "Gy+CL40NFO7uiWO6OOnbxUU97Z2lKx5fO+6Vc9hvO0M=", "n": 2, "pn": 1}, "ciphertext": "kkc637u9qP1ZTwTgk7zwihyDHV+VJBAvSQuSgvCkrcyvDkMS0yur/Vjhwgjq/oWfc2Mfhm7wen5v27eDuxNTz9aMJ1PEsPCpYZ1mcWkzg4q6s1Db8iwA5aQhuZmEFfCumrQR1ZQ2+y4ydI6/x383Lc4fpMp+dyYAiLZvc/dkSzJKA9rHqqgN79OI5wKSMJ7/vTyb/IOPsgnEZ10vgFDXCFOkQ+Rw/lLhSRP7u/kkRLI="}, "0yId5rlRbCFOQz1ycMcmFcs2E5ovmCdsbce7QLODuqrtUK/9o1E/cfqUpv6i0KxTzOzInUh6QqAOhPTvHrV28UZo86I9A5VsdiMqyq69mDlg5Ww6y1aSjsEUNX5RHDPWYRtp2A=="], [{"header": {"ratchet_pub": "g+PxENBD6+/WJIYNkXbIvzEADQYtJh2c8ptLXI79y38=", "n": 1, "pn": 3}, "ciphertext": "XsynIx0aZfCLXqOLuTkoAeNMYui8Bkg9buNVg/E+Q+U6QhnZat/ehB5fEa82CZZqvCSFXOXl5UMalEJKcXy9HKHCvawb/yNMcSpUK5p03VfVz/1KDmrG6fHiT7k909xR4bNw6KqWvk3WmP7/5fRqiYlXdlRSrmiJI+Nk9Lz6LBIDZxq3I0OihIR2mCGvUohSt/Mz2pNq5SvBO4LSNTnpwTJ80yOdGE09QjTkygKSHzo="}, "0yId5rlRbCFOQz1ycMcmFcs2E5ovmCdsbce7QLODuqrtUK/9o1E/cfqUpv6i0KxTzOzInUh6QqAOhPTvHrV28UZo86I9A5VsdiMqyq69mDlg5Ww6y1aSjsEUNX5RHDPWYRtp2A=="], [{"header": {"ratchet_pub": "27ns3MXiqZFv1s0dZJqoDITHvaEn6pm1+FcgJUVMnxY=", "n": 1, "pn": 2}, "ciphertext": "rIRQWBJyGUrE1NBrlzmTRP93vYw+KWmw6ILyXkcz+t/1KwVUjwW+SeTuM4R6fPf7ZRPdTj6k1qnhJkNQ+Cjs6lo3bLbGS8FqoNhyf8NHhEL2SePpS1Ln3weaoW4v/u1G6/5FL2vFsK/X0TTsTqo27C8cMHhKOXMswlKZ1110rY4RtqyVVflhrRWED1NBQeIb3sWggYrA5AA+8g45g+jDsbeSqmbLvnKmcTibE1pWlpY="}, "0yId5rlRbCFOQz1ycMcmFcs2E5ovmCdsbce7QLODuqrtUK/9o1E/cfqUpv6i0KxTzOzInUh6QqAOhPTvHrV28UZo86I9A5VsdiMqyq69mDlg5Ww6y1aSjsEUNX5RHDPWYRtp2A=="]]python-doubleratchet-1.0.3/tests/migration_data/bob-skipped-messages-pre-stable.json000066400000000000000000000036501433244713400307470ustar00rootroot00000000000000[[{"header": {"ratchet_pub": "qGdPkuSJczVrf2ouqdyHPH3Y0qo1Y97AYoDBoJf6W18=", "n": 0, "pn": 1}, "ciphertext": "Zfu5rbEQNwstpc/OzhD2P56ZTlA9KYd6YCu4bkk138JX/295hvVlj6xp1WYr2uZGTfu+JBB+KqbTAZB6aYGzHzLfT8W7p9Fa3LEGlDEImdksR9R6eu0l3NrkCYh2YtfXpNN2pbt/JRzmT31R1cUCm2JrvSedGhBK6fE0e818MF45Z5/H4U28z3bhfKycEu25nbop4+z/253heLkTyG1+IOB785Y/Wrr02TMMGJKuC74="}, "0yId5rlRbCFOQz1ycMcmFcs2E5ovmCdsbce7QLODuqrtUK/9o1E/cfqUpv6i0KxTzOzInUh6QqAOhPTvHrV28UZo86I9A5VsdiMqyq69mDlg5Ww6y1aSjsEUNX5RHDPWYRtp2A=="], [{"header": {"ratchet_pub": "qGdPkuSJczVrf2ouqdyHPH3Y0qo1Y97AYoDBoJf6W18=", "n": 1, "pn": 1}, "ciphertext": "TOwevxvY+Hgg4AsKemZwnxgVI/li9O23xlsn0Q0vAR9vmkXiCPr2TsEL/OSoToz1otHJwr0ZKn3qEU9QKsijvUIX9NWd7IKlpHg2wmS6wLD2+d+Az84LhJm+p9yYIVf3DKC6fTJcQBOKnnTlsQrKsneNf6isN6yvi7j4o8Gjt7G+Zq3bFW+wGBel/IKJO0UpxAKRBp1xyVoe5UhGlGmIK0dwj0N4c7zXevEWTTqHdSY="}, "0yId5rlRbCFOQz1ycMcmFcs2E5ovmCdsbce7QLODuqrtUK/9o1E/cfqUpv6i0KxTzOzInUh6QqAOhPTvHrV28UZo86I9A5VsdiMqyq69mDlg5Ww6y1aSjsEUNX5RHDPWYRtp2A=="], [{"header": {"ratchet_pub": "WgLRqqJltSjbsf2IvXJ/p15qPWTxAQIZMNeCuL2deX8=", "n": 0, "pn": 3}, "ciphertext": "N1kcFKgVLD7rr2TY1p5PWhI49qDAZmRfbmq9y59/6QcPFuDEQ1EzihMihmgC0N5o6eh8uHYVAylZPuZ82drroV+9mZsRfYN3++Uj7wBfKD2RKoXfIInbXUdWUzrzskMeOmo3U8wEfBHA+SWBEesSxsWGORk9WhEJ6oL1pUJgclY3kXFjD5jMvc0oC3jINPmK64/x7CS2XSm3JMAuhiiplMdX5cl63LOBqCZF9jvJFUo="}, "0yId5rlRbCFOQz1ycMcmFcs2E5ovmCdsbce7QLODuqrtUK/9o1E/cfqUpv6i0KxTzOzInUh6QqAOhPTvHrV28UZo86I9A5VsdiMqyq69mDlg5Ww6y1aSjsEUNX5RHDPWYRtp2A=="], [{"header": {"ratchet_pub": "xvjy6oGXT0RWNJ/KQ4az3LvUsp96s/jEcZ58u0s1gxc=", "n": 0, "pn": 2}, "ciphertext": "tuWi9xFaV8339AUl2tKiGIXPVrYmRVtzHpEif4le7iQyJAJH35YOMO0RLBwSVwHto4rV5P3MD8T4cx5bxRK8doItIwtk6xvmK/npwrgE+bnrMsLa3vQS0Z1RWMRY+lLx7wDRu141dtpl+34N47ieysFHbFGDTUOj9UhCpduISujLo4nWIcl0nfY0J/L2EdbSgxrS5vD5phWfnj9hA7IUfQUDPJiCvHU8Gk+wWyShWnA="}, "0yId5rlRbCFOQz1ycMcmFcs2E5ovmCdsbce7QLODuqrtUK/9o1E/cfqUpv6i0KxTzOzInUh6QqAOhPTvHrV28UZo86I9A5VsdiMqyq69mDlg5Ww6y1aSjsEUNX5RHDPWYRtp2A=="]]python-doubleratchet-1.0.3/tests/migration_data/dr-alice-pre-stable.json000066400000000000000000000021471433244713400264230ustar00rootroot00000000000000{"super": {"super": null, "root_chain": {"length": 206, "key": "u24G2w5WlqSohePJb+h3IRVVWy1cXF24nI827mQ7VHo="}, "own_key": {"super": null, "pub": "AkUl7ZVS92l3s7qn93mJ9QLfoKorRT7s3vIypiqcdlw=", "priv": "+MWXKdfin+/RGqvuiwrigTwpLgMDLCm+/ERb68HL7Rc="}, "other_pub": {"super": null, "pub": "xvjy6oGXT0RWNJ/KQ4az3LvUsp96s/jEcZ58u0s1gxc=", "priv": null}}, "skr": {"super": null, "schain": {"length": 1, "key": "laIthyqnFu4gAE5uZOtYjP1kyd1VLciyLa8qE9YOpfw="}, "rchain": {"length": 2, "key": "8IQctYJikgVoQ1SpyzW2kvay1pwSERqjp1FOt4KvkBI="}, "prev_schain_length": 2}, "ad": "dGVzdF9kb3VibGVfcmF0Y2hldCBhc3NvY2lhdGVkIGRhdGE=", "smks": {"{\"pub\": \"qGdPkuSJczVrf2ouqdyHPH3Y0qo1Y97AYoDBoJf6W18=\", \"index\": 0}": "+rTVF3o+XxpER9wBgNNkCgHhVXeN4W52soGrjzbqsk8=", "{\"pub\": \"qGdPkuSJczVrf2ouqdyHPH3Y0qo1Y97AYoDBoJf6W18=\", \"index\": 1}": "JH4zKGLGBY8YeluXRloGHlmOkPZR6xGvKr4gXppsHCM=", "{\"pub\": \"WgLRqqJltSjbsf2IvXJ/p15qPWTxAQIZMNeCuL2deX8=\", \"index\": 0}": "1my2muA6/x24Ne0HVhRrexD07WpYK9ghNae9qffrGgE=", "{\"pub\": \"xvjy6oGXT0RWNJ/KQ4az3LvUsp96s/jEcZ58u0s1gxc=\", \"index\": 0}": "kbQt1qUolFlYQ1W7+OzavySQrXkwMQpXBPbELeAr9gQ="}}python-doubleratchet-1.0.3/tests/migration_data/dr-bob-pre-stable.json000066400000000000000000000021471433244713400261100ustar00rootroot00000000000000{"super": {"super": null, "root_chain": {"length": 207, "key": "Bdv/ipT+8lQTTLlqAwxNfYqyXxBc7nQZ/f/3f5ShN18="}, "own_key": {"super": null, "pub": "XCuEiPJ5ut+uhsjw6sFSxfIIvPSRVu9a1yz5EbESp3o=", "priv": "Bdvo5rdJ+1JH6htAbrQbInX3HLTSsS5pNOA+DZNASdc="}, "other_pub": {"super": null, "pub": "AkUl7ZVS92l3s7qn93mJ9QLfoKorRT7s3vIypiqcdlw=", "priv": null}}, "skr": {"super": null, "schain": {"length": 0, "key": "oPO9jEKKrWQU8FBsfcJPanAshpUddFIGFaXpgrDC9EE="}, "rchain": {"length": 1, "key": "laIthyqnFu4gAE5uZOtYjP1kyd1VLciyLa8qE9YOpfw="}, "prev_schain_length": 2}, "ad": "dGVzdF9kb3VibGVfcmF0Y2hldCBhc3NvY2lhdGVkIGRhdGE=", "smks": {"{\"pub\": \"Gy+CL40NFO7uiWO6OOnbxUU97Z2lKx5fO+6Vc9hvO0M=\", \"index\": 1}": "L6dD8MRRStpz3gPd70NBaAnzlyxoh9jficE/nV6d6wQ=", "{\"pub\": \"Gy+CL40NFO7uiWO6OOnbxUU97Z2lKx5fO+6Vc9hvO0M=\", \"index\": 2}": "c4wOwucjZn1pYitGu3CCsK5XOWNnXy8xLzhdrbQYd58=", "{\"pub\": \"g+PxENBD6+/WJIYNkXbIvzEADQYtJh2c8ptLXI79y38=\", \"index\": 1}": "xUUrxC3dQBVV2UT9jwKTMJC0Empn5GXQTwoLy0gO4Zc=", "{\"pub\": \"27ns3MXiqZFv1s0dZJqoDITHvaEn6pm1+FcgJUVMnxY=\", \"index\": 1}": "4yQbWrkgvy5OKspGTtYQTsZxhNldE8dMyWD8y7btBG8="}}python-doubleratchet-1.0.3/tests/migration_data/uninitialized-dr-pre-stable.json000066400000000000000000000007141433244713400302140ustar00rootroot00000000000000{"super": {"super": null, "root_chain": {"length": 0, "key": "AAECAwQFBgcICQoLDA0OD/Dx8vP09fb3+Pn6+/z9/v8="}, "own_key": {"super": null, "pub": "xyTmRB9AxQYBB7Xeg6LEeFr1iuITxaSiUOG//JT8tnk=", "priv": "MQAfVyYH5/ylnCNyBp6KG/q4o/x+YCgT9VtHbwfcvNI="}, "other_pub": {"super": null, "pub": null, "priv": null}}, "skr": {"super": null, "schain": null, "rchain": null, "prev_schain_length": null}, "ad": "dGVzdF9kb3VibGVfcmF0Y2hldCBhc3NvY2lhdGVkIGRhdGE=", "smks": {}}python-doubleratchet-1.0.3/tests/test_diffie_hellman_ratchet.py000066400000000000000000000331711433244713400251040ustar00rootroot00000000000000from typing import List, Set, Type from warnings import catch_warnings from doubleratchet import ( DoSProtectionException, DuplicateMessageException ) from doubleratchet.diffie_hellman_ratchet import DiffieHellmanRatchet from doubleratchet.recommended import ( diffie_hellman_ratchet_curve25519 as dhr25519, diffie_hellman_ratchet_curve448 as dhr448, HashFunction, kdf_hkdf ) from .test_recommended_kdfs import generate_unique_random_data __all__ = [ # pylint: disable=unused-variable "test_diffie_hellman_ratchet" ] try: import pytest except ImportError: pass else: pytestmark = pytest.mark.asyncio # pylint: disable=unused-variable class RootChainKDF(kdf_hkdf.KDF): """ The root chain KDF to use for testing. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_512 @staticmethod def _get_info() -> bytes: return "test_diffie_hellman_ratchet Root Chain info".encode("ASCII") class MessageChainKDF(kdf_hkdf.KDF): """ The message chain KDF to use for testing. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_512_256 @staticmethod def _get_info() -> bytes: return "test_diffie_hellman_ratchet Message Chain info".encode("ASCII") async def test_diffie_hellman_ratchet() -> None: """ Test the Diffie-Hellman ratchet implementation. """ # pylint: disable=protected-access impls: List[Type[DiffieHellmanRatchet]] = [ dhr25519.DiffieHellmanRatchet, dhr448.DiffieHellmanRatchet ] for impl in impls: root_chain_key_set: Set[bytes] = set() message_chain_constant_set: Set[bytes] = set() for _ in range(100): # Generate random parameters root_chain_key = generate_unique_random_data(32, 32 + 1, root_chain_key_set) message_chain_constant = generate_unique_random_data(0, 2 ** 16, message_chain_constant_set) bob_priv = impl._generate_priv() # Create instances for Alice and Bob and exchange an initial message alice_dhr = await impl.create( None, impl._derive_pub(bob_priv), RootChainKDF, root_chain_key, MessageChainKDF, message_chain_constant, 10 ) encryption_key, header = await alice_dhr.next_encryption_key() bob_dhr = await impl.create( bob_priv, header.ratchet_pub, RootChainKDF, root_chain_key, MessageChainKDF, message_chain_constant, 10 ) decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) assert header.previous_sending_chain_length == 0 assert header.sending_chain_length == 0 assert len(skipped_message_keys) == 0 assert len(encryption_key) == len(decryption_key) == 32 assert encryption_key == decryption_key alice_pub = header.ratchet_pub # Test that Bob can send to Alice now encryption_key, header = await bob_dhr.next_encryption_key() decryption_key, skipped_message_keys = await alice_dhr.next_decryption_key(header) assert header.previous_sending_chain_length == 0 assert header.sending_chain_length == 0 assert len(skipped_message_keys) == 0 assert len(encryption_key) == len(decryption_key) == 32 assert encryption_key == decryption_key assert header.ratchet_pub != impl._derive_pub(bob_priv) bob_pub = header.ratchet_pub # Test that n increases in the header and the ratchet pub stays the same encryption_key, header = await bob_dhr.next_encryption_key() decryption_key, skipped_message_keys = await alice_dhr.next_decryption_key(header) assert header.previous_sending_chain_length == 0 assert header.sending_chain_length == 1 assert len(skipped_message_keys) == 0 assert len(encryption_key) == len(decryption_key) == 32 assert encryption_key == decryption_key assert header.ratchet_pub == bob_pub bob_pub = header.ratchet_pub # Test that switching sender/receiver triggers a Diffie-Hellman ratchet step encryption_key, header = await alice_dhr.next_encryption_key() decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) assert header.previous_sending_chain_length == 1 assert header.sending_chain_length == 0 assert len(skipped_message_keys) == 0 assert len(encryption_key) == len(decryption_key) == 32 assert encryption_key == decryption_key assert header.ratchet_pub != alice_pub alice_pub = header.ratchet_pub # Test that pn is set correctly in the header encryption_key, header = await bob_dhr.next_encryption_key() decryption_key, skipped_message_keys = await alice_dhr.next_decryption_key(header) assert header.previous_sending_chain_length == 2 assert header.sending_chain_length == 0 assert len(skipped_message_keys) == 0 assert len(encryption_key) == len(decryption_key) == 32 assert encryption_key == decryption_key assert header.ratchet_pub != bob_pub bob_pub = header.ratchet_pub # Test a few skipped messages (simple case, no Diffie-Hellman ratchet steps) skipped_encryption_key_1, skipped_header_1 = await bob_dhr.next_encryption_key() skipped_encryption_key_2, skipped_header_2 = await bob_dhr.next_encryption_key() skipped_encryption_key_3, skipped_header_3 = await bob_dhr.next_encryption_key() encryption_key, header = await bob_dhr.next_encryption_key() decryption_key, skipped_message_keys = await alice_dhr.next_decryption_key(header) assert header.previous_sending_chain_length == 2 assert header.sending_chain_length == 4 assert len(skipped_message_keys) == 3 assert len(encryption_key) == len(decryption_key) == 32 assert encryption_key == decryption_key assert header.ratchet_pub == bob_pub bob_pub = header.ratchet_pub # Check the skipped message keys assert skipped_header_1.ratchet_pub == bob_pub assert skipped_header_2.ratchet_pub == bob_pub assert skipped_header_3.ratchet_pub == bob_pub assert skipped_header_1.previous_sending_chain_length == 2 assert skipped_header_2.previous_sending_chain_length == 2 assert skipped_header_3.previous_sending_chain_length == 2 assert skipped_header_1.sending_chain_length == 1 assert skipped_header_2.sending_chain_length == 2 assert skipped_header_3.sending_chain_length == 3 assert skipped_message_keys[(bob_pub, 1)] == skipped_encryption_key_1 assert skipped_message_keys[(bob_pub, 2)] == skipped_encryption_key_2 assert skipped_message_keys[(bob_pub, 3)] == skipped_encryption_key_3 # Test that attempting to acquire one of these keys again raises an exception try: await alice_dhr.next_decryption_key(skipped_header_3) assert False except DuplicateMessageException: pass try: await alice_dhr.next_decryption_key(header) assert False except DuplicateMessageException: pass # Test the more complicated case of skipped message keys (after a Diffie-Hellman ratchet step) skipped_encryption_key, skipped_header = await bob_dhr.next_encryption_key() # Prepare a message encryption_key, header = await alice_dhr.next_encryption_key() # Perform a DH ratchet step decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) encryption_key, header = await bob_dhr.next_encryption_key() # Let Alice decrypt a fresh message decryption_key, skipped_message_keys = await alice_dhr.next_decryption_key(header) assert len(encryption_key) == len(decryption_key) == 32 assert encryption_key == decryption_key assert len(skipped_message_keys) == 1 skipped_message_keys_key = (skipped_header.ratchet_pub, skipped_header.sending_chain_length) assert skipped_message_keys[skipped_message_keys_key] == skipped_encryption_key # Decrypting this message should not raise an exception but mess up the ratchet instead and return # a wrong key: decryption_key, skipped_message_keys = await alice_dhr.next_decryption_key(skipped_header) assert decryption_key != skipped_encryption_key # The ratchets are now completely desynchronized, the only option is creating new ratchets. The # Double Ratchet mitigates this issue. alice_dhr = await impl.create( None, impl._derive_pub(bob_priv), RootChainKDF, root_chain_key, MessageChainKDF, message_chain_constant, 10 ) encryption_key, header = await alice_dhr.next_encryption_key() bob_dhr = await impl.create( bob_priv, header.ratchet_pub, RootChainKDF, root_chain_key, MessageChainKDF, message_chain_constant, 10 ) decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) assert header.previous_sending_chain_length == 0 assert header.sending_chain_length == 0 assert len(skipped_message_keys) == 0 assert len(encryption_key) == len(decryption_key) == 32 assert encryption_key == decryption_key alice_pub = header.ratchet_pub # Test the (hard) DoS protection by skipping more than 10 messages: for _ in range(25): await bob_dhr.next_encryption_key() encryption_key, header = await bob_dhr.next_encryption_key() try: await alice_dhr.next_decryption_key(header) assert False except DoSProtectionException: pass # Perform a Diffie-Hellman ratchet step encryption_key, header = await alice_dhr.next_encryption_key() decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) # Test the (soft) DoS protection: encryption_key, header = await bob_dhr.next_encryption_key() with catch_warnings(record=True) as warnings: decryption_key, skipped_message_keys = await alice_dhr.next_decryption_key(header) assert len(warnings) == 1 assert issubclass(warnings[0].category, UserWarning) assert "DoS" in str(warnings[0].message) assert len(skipped_message_keys) == 0 # Without DoS protection, this would be 25+ assert len(encryption_key) == len(decryption_key) == 32 assert encryption_key == decryption_key # Make sure that a root key of a different size than 32 bytes is rejected try: await impl.create( None, impl._derive_pub(bob_priv), RootChainKDF, b"\00" * 64, MessageChainKDF, message_chain_constant, 10 ) assert False except ValueError as e: assert "key" in str(e) assert "root chain" in str(e) assert "32 bytes" in str(e) # Test that (de)serializing doesn't influence the functionality encryption_key, header = await alice_dhr.next_encryption_key() decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) assert encryption_key == decryption_key alice_dhr = impl.from_json(alice_dhr.json, RootChainKDF, MessageChainKDF, message_chain_constant, 10) encryption_key, header = await alice_dhr.next_encryption_key() decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) assert encryption_key == decryption_key bob_dhr = impl.from_json(bob_dhr.json, RootChainKDF, MessageChainKDF, message_chain_constant, 10) encryption_key, header = await alice_dhr.next_encryption_key() decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) assert encryption_key == decryption_key # Make sure that a message can be decrypted twice by restoring an old serialized state encryption_key, header = await alice_dhr.next_encryption_key() bob_dhr_serialized = bob_dhr.json decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) assert encryption_key == decryption_key bob_dhr = impl.from_json(bob_dhr_serialized, RootChainKDF, MessageChainKDF, message_chain_constant, 10) decryption_key, skipped_message_keys = await bob_dhr.next_decryption_key(header) assert encryption_key == decryption_key python-doubleratchet-1.0.3/tests/test_double_ratchet.py000066400000000000000000000402061433244713400234250ustar00rootroot00000000000000import base64 import copy import json import os from typing import Set, Dict, Any, List, Tuple from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.x448 import X448PrivateKey from doubleratchet import ( AuthenticationFailedException, DoubleRatchet as DR, DuplicateMessageException, EncryptedMessage, Header, InconsistentSerializationException ) from doubleratchet.recommended import ( aead_aes_hmac, diffie_hellman_ratchet_curve25519 as dhr25519, diffie_hellman_ratchet_curve448 as dhr448, HashFunction, kdf_hkdf ) from .test_recommended_kdfs import generate_unique_random_data __all__ = [ # pylint: disable=unused-variable "test_double_ratchet", "test_migrations" ] try: import pytest except ImportError: pass else: pytestmark = pytest.mark.asyncio # pylint: disable=unused-variable class RootChainKDF(kdf_hkdf.KDF): """ The root chain KDF to use while testing. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_256 @staticmethod def _get_info() -> bytes: return "test_double_ratchet Root Chain KDF info".encode("ASCII") class MessageChainKDF(kdf_hkdf.KDF): """ The message chain KDF to use while testing. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_512_256 @staticmethod def _get_info() -> bytes: return "test_double_ratchet Message Chain KDF info".encode("ASCII") class AEAD(aead_aes_hmac.AEAD): """ The AEAD to use while testing. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_512 @staticmethod def _get_info() -> bytes: return "test_double_ratchet AEAD info".encode("ASCII") class DoubleRatchet(DR): """ The Double Ratchet to use while testing. """ @staticmethod def _build_associated_data(associated_data: bytes, header: Header) -> bytes: return ( associated_data + header.ratchet_pub + header.sending_chain_length.to_bytes(8, "big") + header.previous_sending_chain_length.to_bytes(8, "big") ) drc: Dict[str, Any] = { "diffie_hellman_ratchet_class": dhr448.DiffieHellmanRatchet, "root_chain_kdf": RootChainKDF, "message_chain_kdf": MessageChainKDF, "message_chain_constant": "test_double_ratchet Message Chain constant".encode("ASCII"), "dos_protection_threshold": 10, "max_num_skipped_message_keys": 15, "aead": AEAD } async def test_double_ratchet() -> None: """ Test the Double Ratchet implementation. """ shared_secret_set: Set[bytes] = set() message_set: Set[bytes] = set() ad_set: Set[bytes] = set() # for _ in range(200): for _ in range(10): bob_ratchet_priv = X448PrivateKey.generate() bob_ratchet_pub = bob_ratchet_priv.public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw ) shared_secret = generate_unique_random_data(32, 32 + 1, shared_secret_set) message = generate_unique_random_data(0, 2 ** 16, message_set) ad = generate_unique_random_data(0, 2 ** 16, ad_set) # pylint: disable=invalid-name # Test that passing a shared secret which doesn't consist of 32 bytes raises an exception: try: await DoubleRatchet.encrypt_initial_message( shared_secret=b"\x00" * 64, recipient_ratchet_pub=bob_ratchet_pub, message=message, associated_data=ad, **drc ) assert False except ValueError as e: assert "shared secret" in str(e) assert "32 bytes" in str(e) # Test that passing a DoS protection threshold higher than the maximum number of skipped message key # raises an exception: try: drc_copy = copy.copy(drc) drc_copy["dos_protection_threshold"] = 20 await DoubleRatchet.encrypt_initial_message( shared_secret=shared_secret, recipient_ratchet_pub=bob_ratchet_pub, message=message, associated_data=ad, **drc_copy ) assert False except ValueError as e: assert "dos_protection_threshold" in str(e) assert "bigger than" in str(e) assert "max_num_skipped_message_keys" in str(e) # Encrypt an initial message from Alice to Bob alice_dr, encrypted_message = await DoubleRatchet.encrypt_initial_message( shared_secret=shared_secret, recipient_ratchet_pub=bob_ratchet_pub, message=message, associated_data=ad, **drc ) bob_dr, plaintext = await DoubleRatchet.decrypt_initial_message( shared_secret=shared_secret, own_ratchet_priv=bob_ratchet_priv.private_bytes( encoding=serialization.Encoding.Raw, format=serialization.PrivateFormat.Raw, encryption_algorithm=serialization.NoEncryption() ), message=encrypted_message, associated_data=ad, **drc ) assert alice_dr.sending_chain_length == 1 assert alice_dr.receiving_chain_length is None assert bob_dr.sending_chain_length == 0 assert bob_dr.receiving_chain_length == 1 assert plaintext == message # Send a message back and forth assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message assert await alice_dr.decrypt_message(await bob_dr.encrypt_message(message, ad), ad) == message # Make sure that each ciphertext is different even though the message is always the same: encrypted_message_1 = await alice_dr.encrypt_message(message, ad) assert await bob_dr.decrypt_message(encrypted_message_1, ad) == message encrypted_message_2 = await alice_dr.encrypt_message(message, ad) assert await bob_dr.decrypt_message(encrypted_message_2, ad) == message assert encrypted_message_1.ciphertext != encrypted_message_2.ciphertext # Test the first case of skipped messages (without a Diffie-Hellman ratchet step): skipped_message_1 = await alice_dr.encrypt_message(message, ad) skipped_message_2 = await alice_dr.encrypt_message(message, ad) assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message assert await bob_dr.decrypt_message(skipped_message_2, ad) == message assert await bob_dr.decrypt_message(skipped_message_1, ad) == message # Test the second case of skipped messages (with Diffie-Hellman ratchet steps): skipped_message_1 = await alice_dr.encrypt_message(message, ad) skipped_message_2 = await alice_dr.encrypt_message(message, ad) assert await alice_dr.decrypt_message(await bob_dr.encrypt_message(message, ad), ad) == message assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message assert await alice_dr.decrypt_message(await bob_dr.encrypt_message(message, ad), ad) == message skipped_message_3 = await alice_dr.encrypt_message(message, ad) skipped_message_4 = await alice_dr.encrypt_message(message, ad) assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message assert await bob_dr.decrypt_message(skipped_message_4, ad) == message assert await bob_dr.decrypt_message(skipped_message_3, ad) == message assert await bob_dr.decrypt_message(skipped_message_2, ad) == message assert await bob_dr.decrypt_message(skipped_message_1, ad) == message # Test that only the last 15 skipped message keys are kept around: skipped_message = await alice_dr.encrypt_message(message, ad) # Skipped messages: 1 for _ in range(7): await alice_dr.encrypt_message(message, ad) # Skipped messages: 8 assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message for _ in range(7): await alice_dr.encrypt_message(message, ad) # Skipped messages: 15 assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message assert await bob_dr.decrypt_message(skipped_message, ad) == message skipped_message = await alice_dr.encrypt_message(message, ad) # Skipped messages: 1 for _ in range(7): await alice_dr.encrypt_message(message, ad) # Skipped messages: 8 assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message for _ in range(8): await alice_dr.encrypt_message(message, ad) # Skipped messages: 16 assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message try: await bob_dr.decrypt_message(skipped_message, ad) assert False except DuplicateMessageException: pass # Test decrypting a message twice, before a Diffie-Hellman ratchet step. This should throw a # DuplicateMessageException and leave the ratchet intact: encrypted_message = await alice_dr.encrypt_message(message, ad) assert await bob_dr.decrypt_message(encrypted_message, ad) == message try: await bob_dr.decrypt_message(encrypted_message, ad) assert False except DuplicateMessageException: pass # Test decrypting a message twice, after a Diffie-Hellman ratchet step. The Double Ratchet does not # detect this, thus no DuplicateMessageException should be raise, but a generic # AuthenticationFailedException: assert await alice_dr.decrypt_message(await bob_dr.encrypt_message(message, ad), ad) == message assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message assert await alice_dr.decrypt_message(await bob_dr.encrypt_message(message, ad), ad) == message encrypted_message = await alice_dr.encrypt_message(message, ad) assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message assert await alice_dr.decrypt_message(await bob_dr.encrypt_message(message, ad), ad) == message assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message assert await bob_dr.decrypt_message(encrypted_message, ad) == message try: await bob_dr.decrypt_message(encrypted_message, ad) assert False except AuthenticationFailedException: pass # Even after this failure, the ratchets should still work as before: assert await alice_dr.decrypt_message(await bob_dr.encrypt_message(message, ad), ad) == message assert await alice_dr.decrypt_message(await bob_dr.encrypt_message(message, ad), ad) == message assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message # Test that (de)serialization doesn't damage the instances: assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message alice_dr = DoubleRatchet.from_json(alice_dr.json, **drc) assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message bob_dr = DoubleRatchet.from_json(bob_dr.json, **drc) assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message skipped_message = await alice_dr.encrypt_message(message, ad) assert await bob_dr.decrypt_message(await alice_dr.encrypt_message(message, ad), ad) == message bob_dr = DoubleRatchet.from_json(bob_dr.json, **drc) assert await bob_dr.decrypt_message(skipped_message, ad) == message # Test that (de)serialization can be used to decrypt the same message twice: encrypted_message = await alice_dr.encrypt_message(message, ad) bob_dr_serialized = bob_dr.json assert await bob_dr.decrypt_message(encrypted_message, ad) == message bob_dr = DoubleRatchet.from_json(bob_dr_serialized, **drc) assert await bob_dr.decrypt_message(encrypted_message, ad) == message MIGRATION_DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "migration_data") async def test_migrations() -> None: """ Test serialization format migrations. """ double_ratchet_configuration: Dict[str, Any] = { "diffie_hellman_ratchet_class": dhr25519.DiffieHellmanRatchet, "root_chain_kdf": RootChainKDF, "message_chain_kdf": MessageChainKDF, "message_chain_constant": "test_double_ratchet Message Chain constant".encode("ASCII"), "dos_protection_threshold": 10, "max_num_skipped_message_keys": 15, "aead": AEAD } associated_data = "test_double_ratchet associated data".encode("ASCII") with open(os.path.join(MIGRATION_DATA_DIR, "dr-alice-pre-stable.json"), encoding="utf-8") as file: alice_dr_serialized = json.load(file) with open(os.path.join(MIGRATION_DATA_DIR, "dr-bob-pre-stable.json"), encoding="utf-8") as file: bob_dr_serialized = json.load(file) with open(os.path.join(MIGRATION_DATA_DIR, "uninitialized-dr-pre-stable.json"), encoding="utf-8") as file: uninitialized_dr_serialized = json.load(file) with open( os.path.join(MIGRATION_DATA_DIR, "alice-skipped-messages-pre-stable.json"), encoding="utf-8" ) as file: alice_skipped_messages_serialized = json.load(file) with open( os.path.join(MIGRATION_DATA_DIR, "bob-skipped-messages-pre-stable.json"), encoding="utf-8" ) as file: bob_skipped_messages_serialized = json.load(file) alice_skipped_messages: List[Tuple[EncryptedMessage, bytes]] = [] for skipped_message, plaintext in alice_skipped_messages_serialized: alice_skipped_messages.append(( EncryptedMessage( header=Header( ratchet_pub=base64.b64decode(skipped_message["header"]["ratchet_pub"].encode("ASCII")), previous_sending_chain_length=skipped_message["header"]["pn"], sending_chain_length=skipped_message["header"]["n"] ), ciphertext=base64.b64decode(skipped_message["ciphertext"].encode("ASCII")) ), base64.b64decode(plaintext.encode("ASCII")) )) bob_skipped_messages: List[Tuple[EncryptedMessage, bytes]] = [] for skipped_message, plaintext in bob_skipped_messages_serialized: bob_skipped_messages.append(( EncryptedMessage( header=Header( ratchet_pub=base64.b64decode(skipped_message["header"]["ratchet_pub"].encode("ASCII")), previous_sending_chain_length=skipped_message["header"]["pn"], sending_chain_length=skipped_message["header"]["n"] ), ciphertext=base64.b64decode(skipped_message["ciphertext"].encode("ASCII")) ), base64.b64decode(plaintext.encode("ASCII")) )) # Verify that the uninitialized ratchet data can't be migrated try: DoubleRatchet.from_json(uninitialized_dr_serialized, **double_ratchet_configuration) assert False except InconsistentSerializationException: pass # Migrate the two valid ratchets alice_dr = DoubleRatchet.from_json(alice_dr_serialized, **double_ratchet_configuration) bob_dr = DoubleRatchet.from_json(bob_dr_serialized, **double_ratchet_configuration) # Verify that skipped messages can be correctly decrypted using the restored instances for encrypted_message, plaintext in alice_skipped_messages: assert await bob_dr.decrypt_message(encrypted_message, associated_data) == plaintext for encrypted_message, plaintext in bob_skipped_messages: assert await alice_dr.decrypt_message(encrypted_message, associated_data) == plaintext python-doubleratchet-1.0.3/tests/test_kdf_chain.py000066400000000000000000000102021433244713400223400ustar00rootroot00000000000000import random from typing import Set from doubleratchet import KDFChain from doubleratchet.recommended import HashFunction, kdf_hkdf from .test_recommended_kdfs import generate_unique_random_data __all__ = [ # pylint: disable=unused-variable "test_kdf_chain" ] try: import pytest except ImportError: pass else: pytestmark = pytest.mark.asyncio # pylint: disable=unused-variable class KDF(kdf_hkdf.KDF): """ The KDF to use for testing. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_512 @staticmethod def _get_info() -> bytes: return "test_kdf_chain info".encode("ASCII") async def test_kdf_chain() -> None: """ Test the KDF chain implementation. """ initial_key_set: Set[bytes] = set() input_data_set: Set[bytes] = set() output_data_set: Set[bytes] = set() for _ in range(25): # Generate random parameters while True: initial_key = generate_unique_random_data(0, 2 ** 16, initial_key_set) input_data = generate_unique_random_data(0, 2 ** 16, input_data_set) output_data_length = random.randrange(2, 2 ** 16) digest_size = HashFunction.SHA_512.hash_size if len(initial_key) + output_data_length <= 255 * digest_size: break # Create the KDF chain kdf_chain = KDFChain.create(KDF, initial_key) # Perform 100 derivation steps for step_counter in range(100): output_data = await kdf_chain.step(input_data, output_data_length) # Assert correct length and uniqueness of the result assert len(output_data) == output_data_length assert output_data not in output_data_set output_data_set.add(output_data) # Assert that the chain length is counted correctly assert kdf_chain.length == step_counter + 1 # Save the output data derived in the final step to be able to confirm determinism final_step_output_data = output_data # Create another KDF chain with the same parameters output_data_set.clear() kdf_chain = KDFChain.create(KDF, initial_key) # Repeat the 100 derivation steps for step_counter in range(100): output_data = await kdf_chain.step(input_data, output_data_length) # Assert correct length and uniqueness of the result assert len(output_data) == output_data_length assert output_data not in output_data_set output_data_set.add(output_data) # Assert that the chain length is counted correctly assert kdf_chain.length == step_counter + 1 # Assert determinism assert output_data == final_step_output_data # Create another KDF chain with the same parameters output_data_set.clear() kdf_chain = KDFChain.create(KDF, initial_key) # Repeat only the first 50 derivation steps for step_counter in range(50): output_data = await kdf_chain.step(input_data, output_data_length) # Assert correct length and uniqueness of the result assert len(output_data) == output_data_length assert output_data not in output_data_set output_data_set.add(output_data) # Assert that the chain length is counted correctly assert kdf_chain.length == step_counter + 1 # Serialize and deserialize the KDF chain kdf_chain = KDFChain.from_json(kdf_chain.json, KDF) # Perform the remaining 50 derivation steps for step_counter in range(50): output_data = await kdf_chain.step(input_data, output_data_length) # Assert correct length and uniqueness of the result assert len(output_data) == output_data_length assert output_data not in output_data_set output_data_set.add(output_data) # Assert that the chain length is counted correctly assert kdf_chain.length == step_counter + 51 # Assert that the serialization didn't modify the chain assert output_data == final_step_output_data python-doubleratchet-1.0.3/tests/test_recommended_aeads.py000066400000000000000000000215401433244713400240600ustar00rootroot00000000000000import enum import random from typing import Optional, Set, Type from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.padding import PKCS7 from doubleratchet import AuthenticationFailedException, DecryptionFailedException from doubleratchet.recommended import aead_aes_hmac, HashFunction from doubleratchet.recommended.crypto_provider_cryptography import CryptoProviderImpl from .test_recommended_kdfs import generate_unique_random_data __all__ = [ # pylint: disable=unused-variable "test_aead_aes_hmac" ] try: import pytest except ImportError: pass else: pytestmark = pytest.mark.asyncio # pylint: disable=unused-variable def flip_random_bit(data: bytes) -> bytes: """ In an array of bytes, flip a single random bit. Args: data: The data to manipulate. Return: The data with a single bit flipped somewhere. """ if len(data) == 0: return data modify_byte = random.randrange(len(data)) modify_bit = random.randrange(8) data_mut = bytearray(data) data_mut[modify_byte] ^= 1 << modify_bit return bytes(data_mut) @enum.unique class EvilEncryptModification(enum.Enum): """ Enumartion of the evil encryption modifications tested, i.e. where bit flips are inserted. """ ENCRYPTION_KEY = 1 IV = 2 PADDING = 3 CIPHERTEXT = 4 def make_aead( hash_function: HashFunction, info: bytes, modify: Optional[EvilEncryptModification] ) -> Type[aead_aes_hmac.AEAD]: """ Return a subclass of :class:`~doubleratchet.recommended.aead_aes_hmac.AEAD` using given hash function and info, whose :meth:`~doubleratchet.AEAD.encrypt` method was modified to optionally induce a bit flip somewhere. Args: hash_function: The hash function to use. info: The info to use. modify: The modification to perform, if any. Returns: The subclass. """ class AEAD(aead_aes_hmac.AEAD): # pylint: disable=missing-class-docstring @staticmethod def _get_hash_function() -> HashFunction: return hash_function @staticmethod def _get_info() -> bytes: return info @classmethod async def encrypt(cls, plaintext: bytes, key: bytes, associated_data: bytes) -> bytes: # A copy of aead_aes_hmac.AEAD's encrypt implementation, but with bit flips inserted at various # points. encryption_key, authentication_key, iv = await cls.__derive( key, hash_function, info ) if modify is EvilEncryptModification.ENCRYPTION_KEY: # Flip a random bit of the encryption key encryption_key = flip_random_bit(encryption_key) if modify is EvilEncryptModification.IV: # Flip a random bit of the IV iv = flip_random_bit(iv) padder = PKCS7(128).padder() padded_plaintext = padder.update(plaintext) + padder.finalize() if modify is EvilEncryptModification.PADDING: # Flip the most significant bit of the very last byte padded_plaintext_mut = bytearray(padded_plaintext) padded_plaintext_mut[-1] ^= 1 << 7 padded_plaintext = bytes(padded_plaintext_mut) aes = Cipher( algorithms.AES(encryption_key), modes.CBC(iv), backend=default_backend() ).encryptor() ciphertext = aes.update(padded_plaintext) + aes.finalize() # pylint: disable=no-member if modify is EvilEncryptModification.CIPHERTEXT: # Remove the last byte of the ciphertext ciphertext = ciphertext[:-1] # Calculate the authentication tag auth = await CryptoProviderImpl.hmac_calculate( authentication_key, hash_function, associated_data + ciphertext ) # Append the authentication tag to the ciphertext return ciphertext + auth return AEAD async def test_aead_aes_hmac() -> None: """ Test the AES/HMAC-based AEAD recommended implementation. """ for hash_function in HashFunction: key_set: Set[bytes] = set() data_set: Set[bytes] = set() ad_set: Set[bytes] = set() info_set: Set[bytes] = set() for _ in range(100): # Generate (unique) random parameters key = generate_unique_random_data(1, 2 ** 16, key_set) data = generate_unique_random_data(1, 2 ** 6, data_set) associated_data = generate_unique_random_data(1, 2 ** 16, ad_set) info = generate_unique_random_data(0, 2 ** 16, info_set) # Prepare the AEAD UnmodifiedAEAD = make_aead(hash_function, info, None) # Test en-/decryption ciphertext = await UnmodifiedAEAD.encrypt(data, key, associated_data) plaintext = await UnmodifiedAEAD.decrypt(ciphertext, key, associated_data) assert data == plaintext for _ in range(50): # Flip a random bit in the ciphertext and test the reaction during decryption: try: await UnmodifiedAEAD.decrypt(flip_random_bit(ciphertext), key, associated_data) assert False except AuthenticationFailedException: pass # Flip a random bit in the key and test the reaction during decryption: try: await UnmodifiedAEAD.decrypt(ciphertext, flip_random_bit(key), associated_data) assert False except AuthenticationFailedException: pass # Flip a random bit in the associated data and test the reaction during decryption: try: await UnmodifiedAEAD.decrypt(ciphertext, key, flip_random_bit(associated_data)) assert False except AuthenticationFailedException: pass # A DecryptionFailedException can only be triggered by manually crafting a faulty ciphertext but # adding correct authentication on top of it. That means modifications to the key and to the # associated data will always be caught by an AuthenticationFailedException, only modified # ciphertexts with correct auth tag can trigger a DecryptionFailedException. EvilEncryptionKeyAEAD = make_aead(hash_function, info, EvilEncryptModification.ENCRYPTION_KEY) EvilIVAEAD = make_aead(hash_function, info, EvilEncryptModification.IV) EvilPaddingAEAD = make_aead(hash_function, info, EvilEncryptModification.PADDING) EvilCiphertextAEAD = make_aead(hash_function, info, EvilEncryptModification.CIPHERTEXT) ciphertext = await EvilEncryptionKeyAEAD.encrypt(data, key, associated_data) # Due to the modified key, a different plaintext than the original should be decrypted. This # causes either an error in the unpadding or succeeds but produces wrong plaintext: try: plaintext = await UnmodifiedAEAD.decrypt(ciphertext, key, associated_data) # Either the produced plaintext is wrong... assert plaintext != data except DecryptionFailedException as e: # ...or the unpadding fails. assert "padded incorrectly" in str(e) ciphertext = await EvilIVAEAD.encrypt(data, key, associated_data) # The modified IV only influences the first block of the plaintext, thus a modified IV # might neither cause a decryption error nor an unpadding error. Instead, it will likely # succeed but produce a slightly wrong plaintext: try: plaintext = await UnmodifiedAEAD.decrypt(ciphertext, key, associated_data) # Either the produced plaintext is wrong... assert plaintext != data except DecryptionFailedException as e: # ...or the unpadding fails. assert "padded incorrectly" in str(e) ciphertext = await EvilPaddingAEAD.encrypt(data, key, associated_data) try: await UnmodifiedAEAD.decrypt(ciphertext, key, associated_data) assert False except DecryptionFailedException as e: assert "padded incorrectly" in str(e) ciphertext = await EvilCiphertextAEAD.encrypt(data, key, associated_data) try: await UnmodifiedAEAD.decrypt(ciphertext, key, associated_data) assert False except DecryptionFailedException as e: assert "decryption failed" in str(e).lower() python-doubleratchet-1.0.3/tests/test_recommended_kdfs.py000066400000000000000000000112351433244713400237320ustar00rootroot00000000000000import os import random from typing import Set, Type from doubleratchet.recommended import HashFunction, kdf_hkdf, kdf_separate_hmacs __all__ = [ # pylint: disable=unused-variable "test_kdf_hkdf", "test_kdf_separate_hmacs" ] try: import pytest except ImportError: pass else: pytestmark = pytest.mark.asyncio # pylint: disable=unused-variable def make_kdf_hkdf(hash_function: HashFunction, info: bytes) -> Type[kdf_hkdf.KDF]: """ Create a subclass of :class:`~doubleratchet.recommended.kdf_hkdf.KDF` using given hash function and info. Args: hash_function: The hash function to use. info: The info to use. Returns: The subclass. """ class KDF(kdf_hkdf.KDF): # pylint: disable=missing-class-docstring @staticmethod def _get_hash_function() -> HashFunction: return hash_function @staticmethod def _get_info() -> bytes: return info return KDF def make_kdf_separate_hmacs(hash_function: HashFunction) -> Type[kdf_separate_hmacs.KDF]: """ Create a subclass of :class:`~doubleratchet.recommended.kdf_separate_hmacs.KDF` using given hash function. Args: hash_function: The hash function to use. Returns: The subclass. """ class KDF(kdf_separate_hmacs.KDF): # pylint: disable=missing-class-docstring @staticmethod def _get_hash_function() -> HashFunction: return hash_function return KDF def generate_unique_random_data(lower_bound: int, upper_bound: int, data_set: Set[bytes]) -> bytes: """ Generate random data of random length (within certain bounds) and make sure that the generated data is new. Args: lower_bound: The minimum number of bytes. upper_bound: The maximum number of bytes (exclusive). data_set: The set of random data that has been generated before, for uniqueness checks. Returns: The newly generated, unique random data. """ while True: data = os.urandom(random.randrange(lower_bound, upper_bound)) if data not in data_set: data_set.add(data) return data async def test_kdf_hkdf() -> None: """ Test the HKDF-based recommended KDF implementation. """ for hash_function in HashFunction: key_set: Set[bytes] = set() input_data_set: Set[bytes] = set() output_data_set: Set[bytes] = set() info_set: Set[bytes] = set() for _ in range(50): # Generate (unique) random parameters key = generate_unique_random_data(0, 2 ** 16, key_set) input_data = generate_unique_random_data(0, 2 ** 16, input_data_set) info = generate_unique_random_data(0, 2 ** 16, info_set) output_data_length = random.randrange(2, 255 * hash_function.hash_size + 1) # Prepare the KDF KDF = make_kdf_hkdf(hash_function, info) # Perform a key derivation output_data = await KDF.derive(key, input_data, output_data_length) # Assert correct length and uniqueness of the result assert len(output_data) == output_data_length assert output_data not in output_data_set output_data_set.add(output_data) # Assert determinism for _ in range(25): output_data_repeated = await KDF.derive(key, input_data, output_data_length) assert output_data_repeated == output_data async def test_kdf_separate_hmacs() -> None: """ Test the separate HMAC-based recommended KDF implementation. """ for hash_function in HashFunction: key_set: Set[bytes] = set() input_data_set: Set[bytes] = set() output_data_set: Set[bytes] = set() # Prepare the KDF KDF = make_kdf_separate_hmacs(hash_function) for _ in range(50): # Generate (unique) random parameters key = generate_unique_random_data(0, 2 ** 16, key_set) input_data = generate_unique_random_data(1, 2 ** 8, input_data_set) output_data_length = len(input_data) * hash_function.hash_size # Perform a key derivation output_data = await KDF.derive(key, input_data, output_data_length) # Assert correct length and uniqueness of the result assert len(output_data) == output_data_length assert output_data not in output_data_set output_data_set.add(output_data) # Assert determinism for _ in range(25): output_data_repeated = await KDF.derive(key, input_data, output_data_length) assert output_data_repeated == output_data python-doubleratchet-1.0.3/tests/test_symmetric_key_ratchet.py000066400000000000000000000074661433244713400250520ustar00rootroot00000000000000from typing import Set from doubleratchet import ( Chain, ChainNotAvailableException, SymmetricKeyRatchet ) from doubleratchet.recommended import HashFunction, kdf_hkdf from .test_recommended_kdfs import generate_unique_random_data __all__ = [ # pylint: disable=unused-variable "test_symmetric_key_ratchet" ] try: import pytest except ImportError: pass else: pytestmark = pytest.mark.asyncio # pylint: disable=unused-variable class KDF(kdf_hkdf.KDF): """ The KDF to use for testing. """ @staticmethod def _get_hash_function() -> HashFunction: return HashFunction.SHA_512 @staticmethod def _get_info() -> bytes: return "test_symmetric_key_ratchet info".encode("ASCII") async def test_symmetric_key_ratchet() -> None: """ Test the symmetric-key ratchet implementation. """ constant_set: Set[bytes] = set() key_set: Set[bytes] = set() for _ in range(10000): constant = generate_unique_random_data(0, 2 ** 16, constant_set) skr_a = SymmetricKeyRatchet.create(KDF, constant) skr_b = SymmetricKeyRatchet.create(KDF, constant) assert skr_a.previous_sending_chain_length is None assert skr_b.previous_sending_chain_length is None assert skr_a.sending_chain_length is None assert skr_b.sending_chain_length is None assert skr_a.receiving_chain_length is None assert skr_b.receiving_chain_length is None key = generate_unique_random_data(32, 32 + 1, key_set) skr_a.replace_chain(Chain.SENDING, key) skr_b.replace_chain(Chain.RECEIVING, key) assert skr_a.previous_sending_chain_length is None assert skr_b.previous_sending_chain_length is None assert skr_a.sending_chain_length == 0 assert skr_b.sending_chain_length is None assert skr_a.receiving_chain_length is None assert skr_b.receiving_chain_length == 0 try: await skr_a.next_decryption_key() assert False except ChainNotAvailableException as e: assert "receiving chain" in str(e) assert "never initialized" in str(e) try: await skr_b.next_encryption_key() assert False except ChainNotAvailableException as e: assert "sending chain" in str(e) assert "never initialized" in str(e) assert await skr_a.next_encryption_key() == await skr_b.next_decryption_key() assert skr_a.sending_chain_length == 1 assert skr_b.receiving_chain_length == 1 key = generate_unique_random_data(32, 32 + 1, key_set) skr_a.replace_chain(Chain.SENDING, key) skr_b.replace_chain(Chain.RECEIVING, key) key = generate_unique_random_data(32, 32 + 1, key_set) skr_a.replace_chain(Chain.RECEIVING, key) skr_b.replace_chain(Chain.SENDING, key) assert await skr_a.next_encryption_key() == await skr_b.next_decryption_key() assert await skr_a.next_encryption_key() == await skr_b.next_decryption_key() assert await skr_b.next_encryption_key() == await skr_a.next_decryption_key() assert skr_a.previous_sending_chain_length == 1 assert skr_b.previous_sending_chain_length is None assert skr_a.sending_chain_length == 2 assert skr_b.sending_chain_length == 1 assert skr_a.receiving_chain_length == 1 assert skr_b.receiving_chain_length == 2 assert len(await skr_a.next_encryption_key()) == 32 await skr_b.next_decryption_key() try: skr_a.replace_chain(Chain.SENDING, b"\x00" * 64) assert False except ValueError as e: assert "chain key" in str(e) assert "32 bytes" in str(e) assert await skr_a.next_encryption_key() == await skr_b.next_decryption_key()