├── .flake8 ├── .github └── workflows │ └── test-and-publish.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── Makefile ├── _static │ └── .gitkeep ├── conf.py ├── getting_started.rst ├── index.rst ├── installation.rst ├── make.bat ├── requirements.txt ├── serialization_and_migration.rst └── x3dh │ ├── base_state.rst │ ├── crypto_provider.rst │ ├── crypto_provider_cryptography.rst │ ├── identity_key_pair.rst │ ├── migrations.rst │ ├── models.rst │ ├── package.rst │ ├── pre_key_pair.rst │ ├── signed_pre_key_pair.rst │ ├── state.rst │ └── types.rst ├── pylintrc ├── pyproject.toml ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── migration_data │ ├── shared-secret-pre-stable.json │ ├── state-alice-pre-stable.json │ └── state-bob-pre-stable.json └── test_x3dh.py └── x3dh ├── __init__.py ├── base_state.py ├── crypto_provider.py ├── crypto_provider_cryptography.py ├── identity_key_pair.py ├── migrations.py ├── models.py ├── pre_key_pair.py ├── project.py ├── py.typed ├── signed_pre_key_pair.py ├── state.py ├── types.py └── version.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | 3 | max-line-length = 110 4 | doctests = True 5 | ignore = E201,E202,W503 6 | per-file-ignores = 7 | x3dh/project.py:E203 8 | x3dh/__init__.py:F401 9 | -------------------------------------------------------------------------------- /.github/workflows/test-and-publish.yml: -------------------------------------------------------------------------------- 1 | name: Test & Publish 2 | 3 | on: [push, pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "pypy-3.10"] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Update pip 25 | run: python -m pip install --upgrade pip 26 | - name: Build and install python-x3dh 27 | run: pip install . 28 | - name: Install test dependencies 29 | run: pip install --upgrade pytest pytest-asyncio pytest-cov mypy pylint flake8 setuptools 30 | 31 | - name: Type-check using mypy 32 | run: mypy --strict x3dh/ setup.py tests/ 33 | - name: Lint using pylint 34 | run: pylint x3dh/ setup.py tests/ 35 | - name: Format-check using Flake8 36 | run: flake8 x3dh/ setup.py tests/ 37 | - name: Test using pytest 38 | run: pytest --cov=x3dh --cov-report term-missing:skip-covered 39 | 40 | build: 41 | name: Build source distribution and wheel 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - name: Build source distribution and wheel 48 | run: python3 setup.py sdist bdist_wheel 49 | 50 | - uses: actions/upload-artifact@v4 51 | with: 52 | name: sdist 53 | path: | 54 | dist/*.tar.gz 55 | dist/*.whl 56 | 57 | publish: 58 | needs: [test, build] 59 | runs-on: ubuntu-latest 60 | permissions: 61 | id-token: write 62 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') 63 | 64 | steps: 65 | - uses: actions/download-artifact@v4 66 | with: 67 | path: dist 68 | merge-multiple: true 69 | 70 | - name: Publish package distributions to PyPI 71 | uses: pypa/gh-action-pypi-publish@release/v1 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | X3DH.egg-info/ 3 | 4 | __pycache__/ 5 | .pytest_cache/ 6 | .mypy_cache/ 7 | .coverage 8 | 9 | build/ 10 | docs/_build/ 11 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-lts-latest 5 | tools: 6 | python: "3" 7 | apt_packages: 8 | - libsodium-dev 9 | 10 | sphinx: 11 | configuration: docs/conf.py 12 | fail_on_warning: true 13 | 14 | python: 15 | install: 16 | - requirements: docs/requirements.txt 17 | - method: pip 18 | path: . 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | 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). 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.1.0] - 15th of October 2024 10 | 11 | ### Changed 12 | - Drop support for Python3.8, add support for Python3.13, bump PyPy test version to 3.10 13 | - Internal housekeeping, mostly related to pylint 14 | 15 | ## [1.0.4] - 9th of July 2024 16 | 17 | ### Changed 18 | - Removed unnecessary complexity/flexibility by returning `None` instead of `Any` from abstract methods whose return values are not used 19 | - 2024 maintenance (bumped Python versions, adjusted for updates to pydantic, mypy, pylint, pytest and GitHub actions) 20 | 21 | ## [1.0.3] - 8th of November 2022 22 | 23 | ### Changed 24 | - Exclude tests from the packages 25 | 26 | ## [1.0.2] - 5th of November 2022 27 | 28 | ### Changed 29 | - Fixed a bug in the way the storage models were versioned 30 | 31 | ## [1.0.1] - 3rd of November 2022 32 | 33 | ### Added 34 | - Python 3.11 to the list of supported versions 35 | 36 | ## [1.0.0] - 1st of November 2022 37 | 38 | ### Added 39 | - Rewrite for modern, type safe Python 3. 40 | 41 | ### Removed 42 | - Pre-stable (i.e. versions before 1.0.0) changelog omitted. 43 | 44 | [Unreleased]: https://github.com/Syndace/python-x3dh/compare/v1.1.0...HEAD 45 | [1.1.0]: https://github.com/Syndace/python-x3dh/compare/v1.0.4...v1.1.0 46 | [1.0.4]: https://github.com/Syndace/python-x3dh/compare/v1.0.3...v1.0.4 47 | [1.0.3]: https://github.com/Syndace/python-x3dh/compare/v1.0.2...v1.0.3 48 | [1.0.2]: https://github.com/Syndace/python-x3dh/compare/v1.0.1...v1.0.2 49 | [1.0.1]: https://github.com/Syndace/python-x3dh/compare/v1.0.0...v1.0.1 50 | [1.0.0]: https://github.com/Syndace/python-x3dh/releases/tag/v1.0.0 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2024 Tim Henkes (Syndace) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include x3dh/py.typed 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://img.shields.io/pypi/v/X3DH.svg)](https://pypi.org/project/X3DH/) 2 | [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/X3DH.svg)](https://pypi.org/project/X3DH/) 3 | [![Build Status](https://github.com/Syndace/python-x3dh/actions/workflows/test-and-publish.yml/badge.svg)](https://github.com/Syndace/python-x3dh/actions/workflows/test-and-publish.yml) 4 | [![Documentation Status](https://readthedocs.org/projects/python-x3dh/badge/?version=latest)](https://python-x3dh.readthedocs.io/) 5 | 6 | # python-x3dh # 7 | 8 | A Python implementation of the [Extended Triple Diffie-Hellman key agreement protocol](https://signal.org/docs/specifications/x3dh/). 9 | 10 | ## Installation ## 11 | 12 | Install the latest release using pip (`pip install X3DH`) or manually from source by running `pip install .` in the cloned repository. 13 | 14 | ## Differences to the Specification ## 15 | 16 | In the X3DH specification, the identity key is a Curve25519/Curve448 key and [XEdDSA](https://www.signal.org/docs/specifications/xeddsa/) is used to create signatures with it. This library does not support Curve448, however, it supports Ed25519 in addition to Curve25519. You can choose whether the public part of the identity key in the bundle is transferred as Curve25519 or Ed25519. Refer to [the documentation](https://python-x3dh.readthedocs.io/) for details. 17 | 18 | ## Testing, Type Checks and Linting ## 19 | 20 | python-x3dh 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: 21 | 22 | ```sh 23 | $ pip install --upgrade pytest pytest-asyncio pytest-cov mypy pylint flake8 setuptools 24 | $ mypy --strict x3dh/ setup.py tests/ 25 | $ pylint x3dh/ setup.py tests/ 26 | $ flake8 x3dh/ setup.py tests/ 27 | $ pytest --cov=x3dh --cov-report term-missing:skip-covered 28 | ``` 29 | 30 | ## Documentation ## 31 | 32 | View the documentation on [readthedocs.io](https://python-x3dh.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/`. 33 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = X3DH 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Syndace/python-x3dh/fbb8c0ce5ede58a957e918bdafa3c6823ba80532/docs/_static/.gitkeep -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file does only contain a selection of the most common options. For a full list see 4 | # the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, add these 10 | # directories to sys.path here. If the directory is relative to the documentation root, 11 | # use os.path.abspath to make it absolute, like shown here. 12 | import os 13 | import sys 14 | 15 | this_file_path = os.path.dirname(os.path.abspath(__file__)) 16 | sys.path.append(os.path.join(this_file_path, "..", "x3dh")) 17 | 18 | from version import __version__ as __version 19 | from project import project as __project 20 | 21 | # -- Project information ----------------------------------------------------------------- 22 | 23 | project = __project["name"] 24 | author = __project["author"] 25 | copyright = f"{__project['year']}, {__project['author']}" 26 | 27 | # The short X.Y version 28 | version = __version["short"] 29 | # The full version, including alpha/beta/rc tags 30 | release = __version["full"] 31 | 32 | # -- General configuration --------------------------------------------------------------- 33 | 34 | # Add any Sphinx extension module names here, as strings. They can be extensions coming 35 | # with Sphinx (named "sphinx.ext.*") or your custom ones. 36 | extensions = [ 37 | "sphinx.ext.autodoc", 38 | "sphinx.ext.viewcode", 39 | "sphinx.ext.napoleon", 40 | "sphinx_autodoc_typehints" 41 | ] 42 | 43 | # Add any paths that contain templates here, relative to this directory. 44 | templates_path = [ "_templates" ] 45 | 46 | # List of patterns, relative to source directory, that match files and directories to 47 | # ignore when looking for source files. 48 | # This pattern also affects html_static_path and html_extra_path. 49 | exclude_patterns = [ "_build", "Thumbs.db", ".DS_Store" ] 50 | 51 | # -- Options for HTML output ------------------------------------------------------------- 52 | 53 | # The theme to use for HTML and HTML Help pages. See the documentation for a list of 54 | # builtin themes. 55 | html_theme = "sphinx_rtd_theme" 56 | 57 | # Add any paths that contain custom static files (such as style sheets) here, relative to 58 | # this directory. They are copied after the builtin static files, so a file named 59 | # "default.css" will overwrite the builtin "default.css". 60 | html_static_path = [ "_static" ] 61 | 62 | # -- Autodoc Configuration --------------------------------------------------------------- 63 | 64 | # The following two options seem to be ignored... 65 | autodoc_typehints = "description" 66 | autodoc_type_aliases = { type_alias: f"{type_alias}" for type_alias in { 67 | "JSONObject" 68 | } } 69 | 70 | def autodoc_skip_member_handler(app, what, name, obj, skip, options): 71 | # Skip private members, i.e. those that start with double underscores but do not end in underscores 72 | if name.startswith("__") and not name.endswith("_"): 73 | return True 74 | 75 | # Could be achieved using exclude-members, but this is more comfy 76 | if name in { 77 | "__abstractmethods__", 78 | "__dict__", 79 | "__module__", 80 | "__new__", 81 | "__weakref__", 82 | "_abc_impl" 83 | }: return True 84 | 85 | # Skip __init__s without documentation. Those are just used for type hints. 86 | if name == "__init__" and obj.__doc__ is None: 87 | return True 88 | 89 | return None 90 | 91 | def setup(app): 92 | app.connect("autodoc-skip-member", autodoc_skip_member_handler) 93 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | This quick start guide assumes basic knowledge of the `X3DH key agreement protocol `__. 5 | 6 | The abstract class :class:`x3dh.state.State` builds the core of this library. To use it, create a subclass and override the :meth:`~x3dh.state.State._publish_bundle` and :meth:`~x3dh.base_state.BaseState._encode_public_key` methods. You can now create instances using the :meth:`~x3dh.state.State.create` method and perform key agreements using :meth:`~x3dh.base_state.BaseState.get_shared_secret_active` and :meth:`~x3dh.state.State.get_shared_secret_passive`. This method requires a set of configuration parameters, most of them directly correspond to those parameters defined in the X3DH specification. One parameter provides configuration that goes beyond the specification: ``identity_key_format``. The :class:`x3dh.state.State` class performs various maintenance/key management tasks automatically, like pre key refilling and signed pre key rotation. Note that the age check of the signed pre key has to be triggered periodically by the program. If manual key management is required, use the :class:`x3dh.base_state.BaseState` class instead. 7 | 8 | .. _ik-types: 9 | 10 | In the X3DH specification, the identity key is a Curve25519/Curve448 key and `XEdDSA `__ is used to create signatures with it. This library does not support Curve448, however, it supports Ed25519 in addition to Curve25519. You can choose whether the public part of the identity key in the bundle is transferred as Curve25519 or Ed25519 using the mentioned constructor parameter ``identity_key_format``. When generating a new identity key, the library will by default generate a seed-based identity key, which is usable for both Ed25519 and X25519 without the help of XEdDSA. A scalar-based private key, which requires the use of XEdDSA to create and verify signatures, can be used by explicitly passing an instance of :class:`~x3dh.identity_key_pair.IdentityKeyPairPriv` via the ``identity_key_pair`` constructor parameter. 11 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | python-x3dh - A Python implementation of the Extended Triple Diffie-Hellman key agreement protocol. 2 | =================================================================================================== 3 | 4 | .. toctree:: 5 | installation 6 | getting_started 7 | serialization_and_migration 8 | API Documentation 9 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Install the latest release using pip (``pip install X3DH``) or manually from source by running ``pip install .`` in the cloned repository. 5 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=X3DH 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-rtd-theme 3 | sphinx-autodoc-typehints 4 | -------------------------------------------------------------------------------- /docs/serialization_and_migration.rst: -------------------------------------------------------------------------------- 1 | .. _serialization_and_migration: 2 | 3 | Serialization and Migration 4 | =========================== 5 | 6 | python-x3dh 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. 7 | 8 | Migration from pre-stable 9 | ------------------------- 10 | 11 | Migration from pre-stable is provided, however, since the class hierarchy and serialization concept has changed, only whole State objects can be migrated to stable. Use the ``from_json`` method as usual. 12 | -------------------------------------------------------------------------------- /docs/x3dh/base_state.rst: -------------------------------------------------------------------------------- 1 | Module: base_state 2 | ================== 3 | 4 | .. automodule:: x3dh.base_state 5 | :members: 6 | :special-members: 7 | :private-members: 8 | :undoc-members: 9 | :member-order: bysource 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/x3dh/crypto_provider.rst: -------------------------------------------------------------------------------- 1 | Module: crypto_provider 2 | ======================= 3 | 4 | .. automodule:: x3dh.crypto_provider 5 | :members: 6 | :special-members: 7 | :private-members: 8 | :undoc-members: 9 | :member-order: bysource 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/x3dh/crypto_provider_cryptography.rst: -------------------------------------------------------------------------------- 1 | Module: crypto_provider_cryptography 2 | ==================================== 3 | 4 | .. automodule:: x3dh.crypto_provider_cryptography 5 | :members: 6 | :special-members: 7 | :private-members: 8 | :undoc-members: 9 | :member-order: bysource 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/x3dh/identity_key_pair.rst: -------------------------------------------------------------------------------- 1 | Module: identity_key_pair 2 | ========================= 3 | 4 | .. automodule:: x3dh.identity_key_pair 5 | :members: 6 | :special-members: 7 | :private-members: 8 | :undoc-members: 9 | :member-order: bysource 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/x3dh/migrations.rst: -------------------------------------------------------------------------------- 1 | Module: migrations 2 | ================== 3 | 4 | .. automodule:: x3dh.migrations 5 | :members: 6 | :special-members: 7 | :private-members: 8 | :undoc-members: 9 | :member-order: bysource 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/x3dh/models.rst: -------------------------------------------------------------------------------- 1 | Module: models 2 | ============== 3 | 4 | .. automodule:: x3dh.models 5 | :members: 6 | :undoc-members: 7 | :member-order: bysource 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /docs/x3dh/package.rst: -------------------------------------------------------------------------------- 1 | Package: x3dh 2 | ============= 3 | 4 | .. toctree:: 5 | Module: base_state 6 | Module: crypto_provider_cryptography 7 | Module: crypto_provider 8 | Module: identity_key_pair 9 | Module: migrations 10 | Module: models 11 | Module: pre_key_pair 12 | Module: signed_pre_key_pair 13 | Module: state 14 | Module: types 15 | -------------------------------------------------------------------------------- /docs/x3dh/pre_key_pair.rst: -------------------------------------------------------------------------------- 1 | Module: pre_key_pair 2 | ==================== 3 | 4 | .. automodule:: x3dh.pre_key_pair 5 | :members: 6 | :undoc-members: 7 | :member-order: bysource 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /docs/x3dh/signed_pre_key_pair.rst: -------------------------------------------------------------------------------- 1 | Module: signed_pre_key_pair 2 | =========================== 3 | 4 | .. automodule:: x3dh.signed_pre_key_pair 5 | :members: 6 | :undoc-members: 7 | :member-order: bysource 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /docs/x3dh/state.rst: -------------------------------------------------------------------------------- 1 | Module: state 2 | ============= 3 | 4 | .. automodule:: x3dh.state 5 | :members: 6 | :special-members: 7 | :private-members: 8 | :undoc-members: 9 | :member-order: bysource 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/x3dh/types.rst: -------------------------------------------------------------------------------- 1 | Module: types 2 | ============= 3 | 4 | .. automodule:: x3dh.types 5 | :members: 6 | :undoc-members: 7 | :member-order: bysource 8 | :show-inheritance: 9 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MAIN] 2 | 3 | # Analyse import fallback blocks. This can be used to support both Python 2 and 4 | # 3 compatible code, which means that the block might have code that exists 5 | # only in one or another interpreter, leading to false positives when analysed. 6 | analyse-fallback-blocks=yes 7 | 8 | # Clear in-memory caches upon conclusion of linting. Useful if running pylint 9 | # in a server-like mode. 10 | clear-cache-post-run=no 11 | 12 | # Load and enable all available extensions. Use --list-extensions to see a list 13 | # all available extensions. 14 | #enable-all-extensions= 15 | 16 | # In error mode, messages with a category besides ERROR or FATAL are 17 | # suppressed, and no reports are done by default. Error mode is compatible with 18 | # disabling specific errors. 19 | #errors-only= 20 | 21 | # Always return a 0 (non-error) status code, even if lint errors are found. 22 | # This is primarily useful in continuous integration scripts. 23 | #exit-zero= 24 | 25 | # A comma-separated list of package or module names from where C extensions may 26 | # be loaded. Extensions are loading into the active Python interpreter and may 27 | # run arbitrary code. 28 | extension-pkg-allow-list=_libxeddsa 29 | 30 | # Return non-zero exit code if any of these messages/categories are detected, 31 | # even if score is above --fail-under value. Syntax same as enable. Messages 32 | # specified are enabled, while categories only check already-enabled messages. 33 | fail-on= 34 | 35 | # Specify a score threshold under which the program will exit with error. 36 | fail-under=10 37 | 38 | # Interpret the stdin as a python script, whose filename needs to be passed as 39 | # the module_or_package argument. 40 | #from-stdin= 41 | 42 | # Files or directories to be skipped. They should be base names, not paths. 43 | ignore=CVS 44 | 45 | # Add files or directories matching the regular expressions patterns to the 46 | # ignore-list. The regex matches against paths and can be in Posix or Windows 47 | # format. Because '\\' represents the directory delimiter on Windows systems, 48 | # it can't be used as an escape character. 49 | ignore-paths= 50 | 51 | # Files or directories matching the regular expression patterns are skipped. 52 | # The regex matches against base names, not paths. The default value ignores 53 | # Emacs file locks 54 | ignore-patterns= 55 | 56 | # List of module names for which member attributes should not be checked and 57 | # will not be imported (useful for modules/projects where namespaces are 58 | # manipulated during runtime and thus existing member attributes cannot be 59 | # deduced by static analysis). It supports qualified module names, as well as 60 | # Unix pattern matching. 61 | ignored-modules= 62 | 63 | # Python code to execute, usually for sys.path manipulation such as 64 | # pygtk.require(). 65 | #init-hook= 66 | 67 | # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the 68 | # number of processors available to use, and will cap the count on Windows to 69 | # avoid hangs. 70 | jobs=1 71 | 72 | # Control the amount of potential inferred values when inferring a single 73 | # object. This can help the performance when dealing with large functions or 74 | # complex, nested conditions. 75 | limit-inference-results=100 76 | 77 | # List of plugins (as comma separated values of python module names) to load, 78 | # usually to register additional checkers. 79 | load-plugins= 80 | 81 | # Pickle collected data for later comparisons. 82 | persistent=yes 83 | 84 | # Resolve imports to .pyi stubs if available. May reduce no-member messages and 85 | # increase not-an-iterable messages. 86 | prefer-stubs=no 87 | 88 | # Minimum Python version to use for version dependent checks. Will default to 89 | # the version used to run pylint. 90 | py-version=3.9 91 | 92 | # Discover python modules and packages in the file system subtree. 93 | recursive=no 94 | 95 | # Add paths to the list of the source roots. Supports globbing patterns. The 96 | # source root is an absolute path or a path relative to the current working 97 | # directory used to determine a package namespace for modules located under the 98 | # source root. 99 | source-roots= 100 | 101 | # When enabled, pylint would attempt to guess common misconfiguration and emit 102 | # user-friendly hints instead of false-positive error messages. 103 | suggestion-mode=yes 104 | 105 | # Allow loading of arbitrary C extensions. Extensions are imported into the 106 | # active Python interpreter and may run arbitrary code. 107 | unsafe-load-any-extension=no 108 | 109 | # In verbose mode, extra non-checker-related info will be displayed. 110 | #verbose= 111 | 112 | 113 | [BASIC] 114 | 115 | # Naming style matching correct argument names. 116 | argument-naming-style=snake_case 117 | 118 | # Regular expression matching correct argument names. Overrides argument- 119 | # naming-style. If left empty, argument names will be checked with the set 120 | # naming style. 121 | #argument-rgx= 122 | 123 | # Naming style matching correct attribute names. 124 | attr-naming-style=snake_case 125 | 126 | # Regular expression matching correct attribute names. Overrides attr-naming- 127 | # style. If left empty, attribute names will be checked with the set naming 128 | # style. 129 | #attr-rgx= 130 | 131 | # Bad variable names which should always be refused, separated by a comma. 132 | bad-names=foo, 133 | bar, 134 | baz, 135 | toto, 136 | tutu, 137 | tata 138 | 139 | # Bad variable names regexes, separated by a comma. If names match any regex, 140 | # they will always be refused 141 | bad-names-rgxs= 142 | 143 | # Naming style matching correct class attribute names. 144 | class-attribute-naming-style=snake_case 145 | 146 | # Regular expression matching correct class attribute names. Overrides class- 147 | # attribute-naming-style. If left empty, class attribute names will be checked 148 | # with the set naming style. 149 | #class-attribute-rgx= 150 | 151 | # Naming style matching correct class constant names. 152 | class-const-naming-style=UPPER_CASE 153 | 154 | # Regular expression matching correct class constant names. Overrides class- 155 | # const-naming-style. If left empty, class constant names will be checked with 156 | # the set naming style. 157 | #class-const-rgx= 158 | 159 | # Naming style matching correct class names. 160 | class-naming-style=PascalCase 161 | 162 | # Regular expression matching correct class names. Overrides class-naming- 163 | # style. If left empty, class names will be checked with the set naming style. 164 | #class-rgx= 165 | 166 | # Naming style matching correct constant names. 167 | const-naming-style=any 168 | 169 | # Regular expression matching correct constant names. Overrides const-naming- 170 | # style. If left empty, constant names will be checked with the set naming 171 | # style. 172 | #const-rgx= 173 | 174 | # Minimum line length for functions/classes that require docstrings, shorter 175 | # ones are exempt. 176 | docstring-min-length=-1 177 | 178 | # Naming style matching correct function names. 179 | function-naming-style=snake_case 180 | 181 | # Regular expression matching correct function names. Overrides function- 182 | # naming-style. If left empty, function names will be checked with the set 183 | # naming style. 184 | #function-rgx= 185 | 186 | # Good variable names which should always be accepted, separated by a comma. 187 | good-names=i, 188 | j, 189 | e, # exceptions in except blocks 190 | _ 191 | 192 | # Good variable names regexes, separated by a comma. If names match any regex, 193 | # they will always be accepted 194 | good-names-rgxs= 195 | 196 | # Include a hint for the correct naming format with invalid-name. 197 | include-naming-hint=no 198 | 199 | # Naming style matching correct inline iteration names. 200 | inlinevar-naming-style=any 201 | 202 | # Regular expression matching correct inline iteration names. Overrides 203 | # inlinevar-naming-style. If left empty, inline iteration names will be checked 204 | # with the set naming style. 205 | #inlinevar-rgx= 206 | 207 | # Naming style matching correct method names. 208 | method-naming-style=snake_case 209 | 210 | # Regular expression matching correct method names. Overrides method-naming- 211 | # style. If left empty, method names will be checked with the set naming style. 212 | #method-rgx= 213 | 214 | # Naming style matching correct module names. 215 | module-naming-style=snake_case 216 | 217 | # Regular expression matching correct module names. Overrides module-naming- 218 | # style. If left empty, module names will be checked with the set naming style. 219 | #module-rgx= 220 | 221 | # Colon-delimited sets of names that determine each other's naming style when 222 | # the name regexes allow several styles. 223 | name-group= 224 | 225 | # Regular expression which should only match function or class names that do 226 | # not require a docstring. 227 | no-docstring-rgx= 228 | 229 | # List of decorators that produce properties, such as abc.abstractproperty. Add 230 | # to this list to register other decorators that produce valid properties. 231 | # These decorators are taken in consideration only for invalid-name. 232 | property-classes=abc.abstractproperty 233 | 234 | # Regular expression matching correct type alias names. If left empty, type 235 | # alias names will be checked with the set naming style. 236 | #typealias-rgx= 237 | 238 | # Regular expression matching correct type variable names. If left empty, type 239 | # variable names will be checked with the set naming style. 240 | #typevar-rgx= 241 | 242 | # Naming style matching correct variable names. 243 | variable-naming-style=snake_case 244 | 245 | # Regular expression matching correct variable names. Overrides variable- 246 | # naming-style. If left empty, variable names will be checked with the set 247 | # naming style. 248 | #variable-rgx= 249 | 250 | 251 | [CLASSES] 252 | 253 | # Warn about protected attribute access inside special methods 254 | check-protected-access-in-special-methods=no 255 | 256 | # List of method names used to declare (i.e. assign) instance attributes. 257 | defining-attr-methods=__init__, 258 | __new__, 259 | setUp, 260 | __post_init__, 261 | create 262 | 263 | # List of member names, which should be excluded from the protected access 264 | # warning. 265 | exclude-protected=_asdict, 266 | _fields, 267 | _replace, 268 | _source, 269 | _make 270 | 271 | # List of valid names for the first argument in a class method. 272 | valid-classmethod-first-arg=cls 273 | 274 | # List of valid names for the first argument in a metaclass class method. 275 | valid-metaclass-classmethod-first-arg=cls 276 | 277 | 278 | [DESIGN] 279 | 280 | # List of regular expressions of class ancestor names to ignore when counting 281 | # public methods (see R0903) 282 | exclude-too-few-public-methods= 283 | 284 | # List of qualified class names to ignore when counting class parents (see 285 | # R0901) 286 | ignored-parents= 287 | 288 | # Maximum number of arguments for function / method. 289 | max-args=100 290 | 291 | # Maximum number of attributes for a class (see R0902). 292 | max-attributes=100 293 | 294 | # Maximum number of boolean expressions in an if statement (see R0916). 295 | max-bool-expr=10 296 | 297 | # Maximum number of branch for function / method body. 298 | max-branches=100 299 | 300 | # Maximum number of locals for function / method body. 301 | max-locals=100 302 | 303 | # Maximum number of parents for a class (see R0901). 304 | max-parents=10 305 | 306 | # Maximum number of positional arguments for function / method. 307 | max-positional-arguments=100 308 | 309 | # Maximum number of public methods for a class (see R0904). 310 | max-public-methods=100 311 | 312 | # Maximum number of return / yield for function / method body. 313 | max-returns=100 314 | 315 | # Maximum number of statements in function / method body. 316 | max-statements=1000 317 | 318 | # Minimum number of public methods for a class (see R0903). 319 | min-public-methods=0 320 | 321 | 322 | [EXCEPTIONS] 323 | 324 | # Exceptions that will emit a warning when caught. 325 | overgeneral-exceptions=builtins.BaseException,builtins.Exception 326 | 327 | 328 | [FORMAT] 329 | 330 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 331 | expected-line-ending-format= 332 | 333 | # Regexp for a line that is allowed to be longer than the limit. 334 | ignore-long-lines=^\s*(# )??$ 335 | 336 | # Number of spaces of indent required inside a hanging or continued line. 337 | indent-after-paren=4 338 | 339 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 340 | # tab). 341 | indent-string=' ' 342 | 343 | # Maximum number of characters on a single line. 344 | max-line-length=110 345 | 346 | # Maximum number of lines in a module. 347 | max-module-lines=10000 348 | 349 | # Allow the body of a class to be on the same line as the declaration if body 350 | # contains single statement. 351 | single-line-class-stmt=no 352 | 353 | # Allow the body of an if to be on the same line as the test if there is no 354 | # else. 355 | single-line-if-stmt=yes 356 | 357 | 358 | [IMPORTS] 359 | 360 | # List of modules that can be imported at any level, not just the top level 361 | # one. 362 | allow-any-import-level= 363 | 364 | # Allow explicit reexports by alias from a package __init__. 365 | allow-reexport-from-package=yes 366 | 367 | # Allow wildcard imports from modules that define __all__. 368 | allow-wildcard-with-all=no 369 | 370 | # Deprecated modules which should not be used, separated by a comma. 371 | deprecated-modules=optparse,tkinter.tix 372 | 373 | # Output a graph (.gv or any supported image format) of external dependencies 374 | # to the given file (report RP0402 must not be disabled). 375 | ext-import-graph= 376 | 377 | # Output a graph (.gv or any supported image format) of all (i.e. internal and 378 | # external) dependencies to the given file (report RP0402 must not be 379 | # disabled). 380 | import-graph= 381 | 382 | # Output a graph (.gv or any supported image format) of internal dependencies 383 | # to the given file (report RP0402 must not be disabled). 384 | int-import-graph= 385 | 386 | # Force import order to recognize a module as part of the standard 387 | # compatibility libraries. 388 | known-standard-library= 389 | 390 | # Force import order to recognize a module as part of a third party library. 391 | known-third-party= 392 | 393 | # Couples of modules and preferred modules, separated by a comma. 394 | preferred-modules= 395 | 396 | 397 | [LOGGING] 398 | 399 | # The type of string formatting that logging methods do. `old` means using % 400 | # formatting, `new` is for `{}` formatting. 401 | logging-format-style=new 402 | 403 | # Logging modules to check that the string format arguments are in logging 404 | # function parameter format. 405 | logging-modules=logging 406 | 407 | 408 | [MESSAGES CONTROL] 409 | 410 | # Only show warnings with the listed confidence levels. Leave empty to show 411 | # all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, 412 | # UNDEFINED. 413 | confidence=HIGH, 414 | CONTROL_FLOW, 415 | INFERENCE, 416 | INFERENCE_FAILURE, 417 | UNDEFINED 418 | 419 | # Disable the message, report, category or checker with the given id(s). You 420 | # can either give multiple identifiers separated by comma (,) or put this 421 | # option multiple times (only on the command line, not in the configuration 422 | # file where it should appear only once). You can also use "--disable=all" to 423 | # disable everything first and then re-enable specific checks. For example, if 424 | # you want to run only the similarities checker, you can use "--disable=all 425 | # --enable=similarities". If you want to run only the classes checker, but have 426 | # no Warning level messages displayed, use "--disable=all --enable=classes 427 | # --disable=W". 428 | disable=missing-module-docstring, 429 | duplicate-code 430 | 431 | # Enable the message, report, category or checker with the given id(s). You can 432 | # either give multiple identifier separated by comma (,) or put this option 433 | # multiple time (only on the command line, not in the configuration file where 434 | # it should appear only once). See also the "--disable" option for examples. 435 | enable=useless-suppression 436 | 437 | 438 | [METHOD_ARGS] 439 | 440 | # List of qualified names (i.e., library.method) which require a timeout 441 | # parameter e.g. 'requests.api.get,requests.api.post' 442 | timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request 443 | 444 | 445 | [MISCELLANEOUS] 446 | 447 | # List of note tags to take in consideration, separated by a comma. 448 | notes=FIXME, 449 | XXX, 450 | TODO 451 | 452 | # Regular expression of note tags to take in consideration. 453 | notes-rgx= 454 | 455 | 456 | [REFACTORING] 457 | 458 | # Maximum number of nested blocks for function / method body 459 | max-nested-blocks=5 460 | 461 | # Complete name of functions that never returns. When checking for 462 | # inconsistent-return-statements if a never returning function is called then 463 | # it will be considered as an explicit return statement and no message will be 464 | # printed. 465 | never-returning-functions=sys.exit 466 | 467 | # Let 'consider-using-join' be raised when the separator to join on would be 468 | # non-empty (resulting in expected fixes of the type: ``"- " + " - 469 | # ".join(items)``) 470 | suggest-join-with-non-empty-separator=yes 471 | 472 | 473 | [REPORTS] 474 | 475 | # Python expression which should return a score less than or equal to 10. You 476 | # have access to the variables 'fatal', 'error', 'warning', 'refactor', 477 | # 'convention', and 'info' which contain the number of messages in each 478 | # category, as well as 'statement' which is the total number of statements 479 | # analyzed. This score is used by the global evaluation report (RP0004). 480 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 481 | 482 | # Template used to display messages. This is a python new-style format string 483 | # used to format the message information. See doc for all details. 484 | msg-template= 485 | 486 | # Set the output format. Available formats are: text, parseable, colorized, 487 | # json2 (improved json format), json (old json format) and msvs (visual 488 | # studio). You can also give a reporter class, e.g. 489 | # mypackage.mymodule.MyReporterClass. 490 | #output-format= 491 | 492 | # Tells whether to display a full report or only the messages. 493 | reports=no 494 | 495 | # Activate the evaluation score. 496 | score=yes 497 | 498 | 499 | [SIMILARITIES] 500 | 501 | # Comments are removed from the similarity computation 502 | ignore-comments=yes 503 | 504 | # Docstrings are removed from the similarity computation 505 | ignore-docstrings=yes 506 | 507 | # Imports are removed from the similarity computation 508 | ignore-imports=no 509 | 510 | # Signatures are removed from the similarity computation 511 | ignore-signatures=yes 512 | 513 | # Minimum lines number of a similarity. 514 | min-similarity-lines=4 515 | 516 | 517 | [SPELLING] 518 | 519 | # Limits count of emitted suggestions for spelling mistakes. 520 | max-spelling-suggestions=4 521 | 522 | # Spelling dictionary name. No available dictionaries : You need to install 523 | # both the python package and the system dependency for enchant to work. 524 | spelling-dict= 525 | 526 | # List of comma separated words that should be considered directives if they 527 | # appear at the beginning of a comment and should not be checked. 528 | spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: 529 | 530 | # List of comma separated words that should not be checked. 531 | spelling-ignore-words= 532 | 533 | # A path to a file that contains the private dictionary; one word per line. 534 | spelling-private-dict-file= 535 | 536 | # Tells whether to store unknown words to the private dictionary (see the 537 | # --spelling-private-dict-file option) instead of raising a message. 538 | spelling-store-unknown-words=no 539 | 540 | 541 | [STRING] 542 | 543 | # This flag controls whether inconsistent-quotes generates a warning when the 544 | # character used as a quote delimiter is used inconsistently within a module. 545 | check-quote-consistency=no 546 | 547 | # This flag controls whether the implicit-str-concat should generate a warning 548 | # on implicit string concatenation in sequences defined over several lines. 549 | check-str-concat-over-line-jumps=no 550 | 551 | 552 | [TYPECHECK] 553 | 554 | # List of decorators that produce context managers, such as 555 | # contextlib.contextmanager. Add to this list to register other decorators that 556 | # produce valid context managers. 557 | contextmanager-decorators=contextlib.contextmanager 558 | 559 | # List of members which are set dynamically and missed by pylint inference 560 | # system, and so shouldn't trigger E1101 when accessed. Python regular 561 | # expressions are accepted. 562 | generated-members= 563 | 564 | # Tells whether to warn about missing members when the owner of the attribute 565 | # is inferred to be None. 566 | ignore-none=no 567 | 568 | # This flag controls whether pylint should warn about no-member and similar 569 | # checks whenever an opaque object is returned when inferring. The inference 570 | # can return multiple potential results while evaluating a Python object, but 571 | # some branches might not be evaluated, which results in partial inference. In 572 | # that case, it might be useful to still emit no-member and other checks for 573 | # the rest of the inferred objects. 574 | ignore-on-opaque-inference=no 575 | 576 | # List of symbolic message names to ignore for Mixin members. 577 | ignored-checks-for-mixins=no-member, 578 | not-async-context-manager, 579 | not-context-manager, 580 | attribute-defined-outside-init 581 | 582 | # List of class names for which member attributes should not be checked (useful 583 | # for classes with dynamically set attributes). This supports the use of 584 | # qualified names. 585 | ignored-classes=optparse.Values,thread._local,_thread._local 586 | 587 | # Show a hint with possible names when a member name was not found. The aspect 588 | # of finding the hint is based on edit distance. 589 | missing-member-hint=yes 590 | 591 | # The minimum edit distance a name should have in order to be considered a 592 | # similar match for a missing member name. 593 | missing-member-hint-distance=1 594 | 595 | # The total number of similar names that should be taken in consideration when 596 | # showing a hint for a missing member. 597 | missing-member-max-choices=1 598 | 599 | # Regex pattern to define which classes are considered mixins. 600 | mixin-class-rgx=.*[Mm]ixin 601 | 602 | # List of decorators that change the signature of a decorated function. 603 | signature-mutators= 604 | 605 | 606 | [VARIABLES] 607 | 608 | # List of additional names supposed to be defined in builtins. Remember that 609 | # you should avoid defining new builtins when possible. 610 | additional-builtins= 611 | 612 | # Tells whether unused global variables should be treated as a violation. 613 | allow-global-unused-variables=yes 614 | 615 | # List of names allowed to shadow builtins 616 | allowed-redefined-builtins= 617 | 618 | # List of strings which can identify a callback function by name. A callback 619 | # name must start or end with one of those strings. 620 | callbacks=cb_, 621 | _cb 622 | 623 | # A regular expression matching the name of dummy variables (i.e. expected to 624 | # not be used). 625 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 626 | 627 | # Argument names that match this expression will be ignored. 628 | ignored-argument-names=_.*|^ignored_|^unused_ 629 | 630 | # Tells whether we should check for unused import in __init__ files. 631 | init-import=no 632 | 633 | # List of qualified module names which can have objects that can redefine 634 | # builtins. 635 | redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io 636 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ "setuptools", "wheel" ] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | XEdDSA>=1.0.0,<2 2 | cryptography>=3.3.2 3 | pydantic>=1.7.4 4 | typing-extensions>=4.3.0 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=exec-used 2 | import os 3 | from typing import Dict, Union, List 4 | 5 | from setuptools import setup, find_packages # type: ignore[import-untyped] 6 | 7 | source_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "x3dh") 8 | 9 | version_scope: Dict[str, Dict[str, str]] = {} 10 | with open(os.path.join(source_root, "version.py"), encoding="utf-8") as f: 11 | exec(f.read(), version_scope) 12 | version = version_scope["__version__"] 13 | 14 | project_scope: Dict[str, Dict[str, Union[str, List[str]]]] = {} 15 | with open(os.path.join(source_root, "project.py"), encoding="utf-8") as f: 16 | exec(f.read(), project_scope) 17 | project = project_scope["project"] 18 | 19 | with open("README.md", encoding="utf-8") as f: 20 | long_description = f.read() 21 | 22 | classifiers = [ 23 | "Intended Audience :: Developers", 24 | 25 | "License :: OSI Approved :: MIT License", 26 | 27 | "Programming Language :: Python :: 3", 28 | "Programming Language :: Python :: 3 :: Only", 29 | 30 | "Programming Language :: Python :: 3.9", 31 | "Programming Language :: Python :: 3.10", 32 | "Programming Language :: Python :: 3.11", 33 | "Programming Language :: Python :: 3.12", 34 | "Programming Language :: Python :: 3.13", 35 | 36 | "Programming Language :: Python :: Implementation :: CPython", 37 | "Programming Language :: Python :: Implementation :: PyPy" 38 | ] 39 | 40 | classifiers.extend(project["categories"]) 41 | 42 | if version["tag"] == "alpha": 43 | classifiers.append("Development Status :: 3 - Alpha") 44 | 45 | if version["tag"] == "beta": 46 | classifiers.append("Development Status :: 4 - Beta") 47 | 48 | if version["tag"] == "stable": 49 | classifiers.append("Development Status :: 5 - Production/Stable") 50 | 51 | del project["categories"] 52 | del project["year"] 53 | 54 | setup( 55 | version=version["short"], 56 | long_description=long_description, 57 | long_description_content_type="text/markdown", 58 | license="MIT", 59 | packages=find_packages(exclude=["tests"]), 60 | install_requires=[ 61 | "XEdDSA>=1.0.0,<2", 62 | "cryptography>=3.3.2", 63 | "pydantic>=1.7.4", 64 | "typing-extensions>=4.3.0" 65 | ], 66 | python_requires=">=3.9", 67 | include_package_data=True, 68 | zip_safe=False, 69 | classifiers=classifiers, 70 | **project 71 | ) 72 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # To make relative imports work 2 | -------------------------------------------------------------------------------- /tests/migration_data/shared-secret-pre-stable.json: -------------------------------------------------------------------------------- 1 | {"to_other": {"ik": "q36WfYhEbfPBlSLjATVvyUgLhmOzKsK30WRJQqmPllo=", "ek": "P8e+JapT/kxLGPsdLfv0ZxR/I29nNzvBC5dhH2wJuiA=", "otpk": "G0zzGeHaZ0URWEpvgOGuOSOKyxTQJ/+vNjXdLowTIVk=", "spk": "rPFiK18xLb9Qt3z8PO5i8mHdG5bpXipxz1qC4VaODnU="}, "ad": "Q3VydmUyNTUxOUKrfpZ9iERt88GVIuMBNW/JSAuGY7MqwrfRZElCqY+WWhM3TW9udEN1cnZlMjU1MTlCQZDNTFGAfVS/c2T551G1pJVI2lfzG/Lm6ybnWBBvmx4TN01vbnQ=", "sk": "S4LP8nTWJpCiEOCdvE1I/kO3PmFag3Etgc3UXer7iqQ="} -------------------------------------------------------------------------------- /tests/migration_data/state-alice-pre-stable.json: -------------------------------------------------------------------------------- 1 | { 2 | "changed": false, 3 | "ik": { 4 | "super": null, 5 | "priv": "COW/g93pqWP0dsS8HcF+LNdsIYw/jRSw4dWyQGS9knY=", 6 | "pub": "q36WfYhEbfPBlSLjATVvyUgLhmOzKsK30WRJQqmPllo=" 7 | }, 8 | "spk": { 9 | "key": { 10 | "super": null, 11 | "priv": "QMmzJcYGV2+5Oyp+mKowxxPALFrVxwQ72ioprII5YF0=", 12 | "pub": "HKiuQbSexwqBsOKLxha9cauFwdP4TbU32e8+ZSz6G3Q=" 13 | }, 14 | "signature": "lzZNC9fjSTY+DqHGAKZU7O/OAXb5oG3AAwN4DoXuYxEErD6JgVSiGwwuGHXd12dfaDIxLAoStCW3ZrJc9+/jBA==", 15 | "timestamp": 1585394058.4946976 16 | }, 17 | "otpks": [ 18 | { 19 | "super": null, 20 | "priv": "wLT6HA7O0EAEU4xeOZWTRWKh7BbMEmRtY3IxoLf6f18=", 21 | "pub": "sQlp0vbi/8nJ9P97fE4aZpiQiMOu6JFstd4VbRESPls=" 22 | }, 23 | { 24 | "super": null, 25 | "priv": "WJ5Z1qmrsTGOy6nxaIptGHSfZmTT1UjiipesCpzYxVk=", 26 | "pub": "MJ1y1tPY8nJoTutl9UwMUdlEnNK4dJexB0Fd0N2CpVI=" 27 | }, 28 | { 29 | "super": null, 30 | "priv": "qO7tYrc9+NUoKOvzjH79i0+lariRk1bqEneRuBGdh2U=", 31 | "pub": "jEa+FT4AoqWkh2RfIY06NYWHcgZU3lQwU1MmPZ9kKAM=" 32 | }, 33 | { 34 | "super": null, 35 | "priv": "yGNOWF0X+EgCyZhax9AtG1a2iO6vwhF+jwceclmgL1M=", 36 | "pub": "q3tJFwkNlM51syo5Atp+JrQ+KVmlezkeIpxkRRo+yFU=" 37 | }, 38 | { 39 | "super": null, 40 | "priv": "UPjGG3Iw3RYQ5WRXVeGzrbTPMgZEZ47Ou3EeAC2B/GU=", 41 | "pub": "wJ9kxrUwS68Ogweg3LCzNFXX6OJWQoWKklYxGAGXW1k=" 42 | }, 43 | { 44 | "super": null, 45 | "priv": "qNoQLnQN27nbtBc7toCFUDHSo1cAhbsIIGnJVyrazHA=", 46 | "pub": "4nz0N6yyyT1ia2/Y9U9MBCwh95UU5BxRn76EUEdX8Bs=" 47 | }, 48 | { 49 | "super": null, 50 | "priv": "QIXSZ/seoykUvIq4dIE0JdnlBf7ZHZW6/TICxVQQkH8=", 51 | "pub": "e2CHEu+lLVkbWbr1ghDemuySWNkyvS9uPIxKLk0ccmw=" 52 | }, 53 | { 54 | "super": null, 55 | "priv": "uC/iC+q2n0wbtBpjmhrHV5wqMxfZ8QmkfPucnxBAg2Q=", 56 | "pub": "cK0Umloz6b7xVglTPjc6prlFqckc9RWwG7cXQuZ2OXY=" 57 | }, 58 | { 59 | "super": null, 60 | "priv": "AL1AcY2whrqABXneLJ60WsbHQTFZOVLXphVRuCrdxFM=", 61 | "pub": "5ub1i6FtF+H9opeC7xNUVfq7DehzJv0/jT2ZJNUkTSA=" 62 | }, 63 | { 64 | "super": null, 65 | "priv": "kItPMETM4pIdhT8ghfpX8CqefW4O+5aE5uSE62f9r2E=", 66 | "pub": "UKHUHfiaZv5hRGhzyOeOFdvdm3aRemx67MxCIqHVr3I=" 67 | }, 68 | { 69 | "super": null, 70 | "priv": "GD9SNN56Ws8iLozvgwXEtg2X5TddEyFY7oj/Hm9VaEo=", 71 | "pub": "ItIweGNnXE0n2MB6T8JY/St/OKFVI7FtEC8ljBIOlhY=" 72 | }, 73 | { 74 | "super": null, 75 | "priv": "SEmOEb0SB5TumarbCJmW2A4CJnyT8fdocLDXFJ5uIl4=", 76 | "pub": "1LJVkuPmBLERl6Y829ZqZuv5Qf4/PgKFFyE8V9SrsBs=" 77 | }, 78 | { 79 | "super": null, 80 | "priv": "WJAqWd4puKS/y2lKG37dhHLJRN6snKkoAo6HrW91xH0=", 81 | "pub": "lVTakwmcRCIu02Lb+OdyVUufkWlij1/V5aSPcS9VOA0=" 82 | }, 83 | { 84 | "super": null, 85 | "priv": "GJ1/4uf56uwR6BR4qymcGBL5ATLiQVI7cz1xn+084HE=", 86 | "pub": "mw2lIlm7/OhO3Qrqvt2J6kqeCyW0+N1DhEN3/o/s/Fg=" 87 | }, 88 | { 89 | "super": null, 90 | "priv": "GL9hHChWu4/c5UwlLnKEZp/ivFl92JidkFIuA9PbIkA=", 91 | "pub": "i8Yo+cyBK7OgLJ8B9kw9tIIaiPvmvjxpfWg+x8ckogI=" 92 | }, 93 | { 94 | "super": null, 95 | "priv": "wIwPkNolAV6zK3HSmdRR9vPz11NDDmi3Qkl8v5gguXc=", 96 | "pub": "YvQcftEI4DsVt0nNBLO+SLQMksYy8/k+hYoGp5TJ8jg=" 97 | }, 98 | { 99 | "super": null, 100 | "priv": "sC4hgdZ2VO3tZ6iPCQTjOtmFeTHixT0FvDuzx9YIins=", 101 | "pub": "Ow3p34cyKTJJRxPUQ7CGpXTi+8F/X9LX/jHKFb6zqSE=" 102 | }, 103 | { 104 | "super": null, 105 | "priv": "sPGlbyW5XrqqDP9E33QIq6OR4umtoKEVTVXG4iaLSXo=", 106 | "pub": "jbQEr3I78UKhXQzcPBaccELfYy8b3C9z4Z7MBOSMjEE=" 107 | }, 108 | { 109 | "super": null, 110 | "priv": "ECV1z2gTjFWGYYRRzTQkJE/L1sfuCk9G8+13VxmoyXA=", 111 | "pub": "7d2wcoytWBcCn/SfJZhZ0uoFcsa/vtowpGdhMW4guXI=" 112 | }, 113 | { 114 | "super": null, 115 | "priv": "iJQoi4NiIWbOWjVrL/36IiFq1UOletpK6FSZkf3p5UE=", 116 | "pub": "thlA8JZfB3V1rjxOEy9Wuk/MJp82tfeyhr0t3SYpfWQ=" 117 | }, 118 | { 119 | "super": null, 120 | "priv": "iEP1V/vqYqHbJ1ls4Z2Ttf9pXYl6TzSvRLKPQqOznHU=", 121 | "pub": "xR+c6UBMjdN2fbCQJbu0xql0KpF61tkzzSsMr/8d+Sk=" 122 | }, 123 | { 124 | "super": null, 125 | "priv": "wDEqzV4Pv0CygeD85VVje2GmwngLJ7jiGPGrHB7ka3A=", 126 | "pub": "b6aXeXUd57Yki2IzYZN2dhA6F6758H5Hy2zaOgbkVDI=" 127 | }, 128 | { 129 | "super": null, 130 | "priv": "QO0oXuQ+TMdCC0WJ8ypATAGI/yV3JpTFjtlgOr56oGk=", 131 | "pub": "PfB7N/RhYta4fQiGL1B2tCHxRKhHWR3DYa79+D/I5Ro=" 132 | }, 133 | { 134 | "super": null, 135 | "priv": "2IJCvkTiGuTfOOuyeoUSQsq68Qrkhzp2CT+BrfRXalE=", 136 | "pub": "dewaSXUmaKOMJQQM7Frns92xF3mzZkjNEFz86zTBTVE=" 137 | }, 138 | { 139 | "super": null, 140 | "priv": "cP7NhzyKOOyHU3hyxhFzeB0x7ySMpaoDwpyUdJ+IymY=", 141 | "pub": "KA57jqjwMYukDlzIe7r/pbYMUIWJXd+FiVji5ykQeUM=" 142 | }, 143 | { 144 | "super": null, 145 | "priv": "CHyMRAn3yRXhykpG86CRq5CVNpN9n1NaFBXepANCnH8=", 146 | "pub": "fH7LUMxfvNNUfBDTK+kS5jQ2vLooWHvSujqWbNMExCs=" 147 | }, 148 | { 149 | "super": null, 150 | "priv": "QL6GDsqX1+sG+ZrMzTQzQqy5hGRitkgbV4Y8q2iJvkw=", 151 | "pub": "EAk/7MToVcpxUduG1ZPKZY46P0Pu3oQy6ylQJ1G/SEk=" 152 | }, 153 | { 154 | "super": null, 155 | "priv": "MBzMtSrYjtMfDVO1UukUfMChO5EnheASB9/NGMvH6mU=", 156 | "pub": "ycgAl+TobnBJpygwBt5bxQeORIqHE+WAe0GbRsmtcBY=" 157 | }, 158 | { 159 | "super": null, 160 | "priv": "2DwI8U4G1VkZWTt5t5Oxh+BPiMrQP/Xz6u99NWqW1kc=", 161 | "pub": "+E08DTDaY+OkpxxfKB8nMfbLp0zMMBFoAzd/Yiq7bE8=" 162 | }, 163 | { 164 | "super": null, 165 | "priv": "0HPbzVXhAjX4+jSiYYinItcNk/vscUbJFXoRJUwStU4=", 166 | "pub": "Bi4nuYkgg+qIcAgSxAZlqjFNo8OE1BP67lqrUrX+HkM=" 167 | }, 168 | { 169 | "super": null, 170 | "priv": "kIqjv+UYAaWARubzxjzvtJRLSY+joyCCqQUnxiHcilU=", 171 | "pub": "PTqRXBSjt5Qj421qFRrjpO3KYiZBFBVK1unTb3J2Dhw=" 172 | }, 173 | { 174 | "super": null, 175 | "priv": "iLnElRpWuf+M0Z7aKAIQTA8EoAVHV0OJo4CvAVQq62k=", 176 | "pub": "Q4iUotEPBje3jXSl/uMeMm93j5PJxqLberWnFI4GgU8=" 177 | }, 178 | { 179 | "super": null, 180 | "priv": "mDZ4QdXZtqwYbE2lRZxGc4zJlYf6yMJ0dwOtJKwGG2w=", 181 | "pub": "kzDrCaY6ipxhycWTQm/7OMsnDsJlecuAnJ8dkyllX1Q=" 182 | }, 183 | { 184 | "super": null, 185 | "priv": "8GywpwCJb5whw1WAJh8/NgDdmD5o5Q9iYfRwr5bn2Vw=", 186 | "pub": "7Yld83m8a9p/5tV20IOO5+Mz0Zd0Cm43oo4HWxRUbDo=" 187 | }, 188 | { 189 | "super": null, 190 | "priv": "0Hh80bZ9F5beR2PWN8D3akBvOiF/M824u4waQedWB20=", 191 | "pub": "a1QuOTPryGWWHaeYS69n1g1+/M8jWDs8n61ev/SUXUU=" 192 | }, 193 | { 194 | "super": null, 195 | "priv": "WHJyiCVx/KN8ohHO+KbN6Az67S7LniTY7Ko1ILGKiWE=", 196 | "pub": "xBmyZuxJ8OxlPvkH3cMSOYm50qx50lFq8oURqJdxxjA=" 197 | }, 198 | { 199 | "super": null, 200 | "priv": "GH4Q64p7s7+pWIdmmwur4UzvqfCkT9ugIuxMKd6AHXk=", 201 | "pub": "/efvJ2k0wBDzeW8WOfXzd90H66hoZyUgbAeXENDeUiU=" 202 | }, 203 | { 204 | "super": null, 205 | "priv": "QLGQ9Da9L7HnHfOtf7IUrhxZbPIqe/u57pCFTamOvXY=", 206 | "pub": "hOkNCJzK0oDmEHGTdCC8rjL6QDFCNR8V/BoDrxOFNnw=" 207 | }, 208 | { 209 | "super": null, 210 | "priv": "oIAXIsS4M2BEihbcO6JZhY5pVoCvgihoVmFtcKI562w=", 211 | "pub": "3vc5o5500cHLKJCzWu1TZ/I5CNYWPpjKgkQQSsFZsgo=" 212 | }, 213 | { 214 | "super": null, 215 | "priv": "KAhBSXRqJcpDjjcsm8cnYYtbBib6vS6IapgF9jH4cUU=", 216 | "pub": "m87HPlbXy+2iJAlQbp5dcbmujmaaO/k5RmFp8yNg7B8=" 217 | }, 218 | { 219 | "super": null, 220 | "priv": "kPuJsVcTErd7dEbWqNFEjcQi2k9eHmqrT5L+qjQO43Q=", 221 | "pub": "RBHMqSNp16ExI1fgGUCZj1oaalLn2xJyZzSNqWrVlxM=" 222 | }, 223 | { 224 | "super": null, 225 | "priv": "IBfcTjB5sGAYJ21N5xq18U0n+jFchuOaF9ejqya0pkY=", 226 | "pub": "HZcfd+SN/jssj06731jplSXTHNpFo0y9V8Xol3Vu9zs=" 227 | }, 228 | { 229 | "super": null, 230 | "priv": "6B0G5vHH3TsLQT/UZ287wD/4y8hJbc5HaqmKb7KkAU4=", 231 | "pub": "jVwByTg+JZenbHSMQKJltbIRRyeVMb4dwMjZX0+pRUo=" 232 | }, 233 | { 234 | "super": null, 235 | "priv": "GN0N5i5l5lc0o+UkrpXEfhRw5xWs9bFaGxPKh0DvWmk=", 236 | "pub": "/NjckKJ7OTMoz0i2SnuRTPz/V5vOJnpJAX7GEr2SVQg=" 237 | }, 238 | { 239 | "super": null, 240 | "priv": "UAkjVI2SQl3pzFiOGNrJKnI2Fe2a1JUrtwvBYuBf2k8=", 241 | "pub": "vN9hay3uV/gy705xdH1OoC6QBlgmPRMTJKziW3XyVnc=" 242 | }, 243 | { 244 | "super": null, 245 | "priv": "yALeQ9YA046l315K/lY+7537GfImwWo+sIdsErMjtk8=", 246 | "pub": "Xu70GzuXq+iFQeGmv9ZxemTYJ84nxVHxDoWeZO1Y8yU=" 247 | }, 248 | { 249 | "super": null, 250 | "priv": "EMIIXgyDpAXd0/xso6Y2I+e2VM2OKUs8hjFL3izYMG0=", 251 | "pub": "a+ttEdfNSdrh49rziKpKlXUa/DVHDbYkW2XyrNzTE1Q=" 252 | }, 253 | { 254 | "super": null, 255 | "priv": "iDmOVjL8tg0cE1bcO+T+MXSHcxvMsBbqqwyhkmihHlw=", 256 | "pub": "hG4TYHDxudD+MNzj2YIGQN6VygWmPzcDTgPi2YuLUTo=" 257 | }, 258 | { 259 | "super": null, 260 | "priv": "iN1c7kDVvbPk/S9ZkMR3BBdPxRXaqeHxpb8awYsL6XM=", 261 | "pub": "ur2zl+oMH8v++3Z03H9yT9uCXN+5RYSlyVBE2c9tNF0=" 262 | }, 263 | { 264 | "super": null, 265 | "priv": "6DTDOFbPkfXfuFZNa0gX3Fw8lQ/RF/vwedt5NUZkTF8=", 266 | "pub": "mbd6dRHtsrhVelXtEZIYfxq/L0beqC2clOZFmNWqFB4=" 267 | }, 268 | { 269 | "super": null, 270 | "priv": "eMwxgDNMUWLW4BLbBATU0FpBNibqB8i30m9NdJn2JmI=", 271 | "pub": "aRxA+LQaY2lfZh4g00lMtJLf8vZXgaL67fS/GRbFhXQ=" 272 | }, 273 | { 274 | "super": null, 275 | "priv": "qEPRzSZuHPpK+HKBR3F2vodxLOOTNCKT7xiMWEV/zng=", 276 | "pub": "GEl9rmEw+2vP2e+H1rpt0/e1m5vhNAC1XIcoVlXXDm4=" 277 | }, 278 | { 279 | "super": null, 280 | "priv": "CKbsLCL56gRHCUQt8Kdw0z4/7j9pS3833VKCVl1r+2c=", 281 | "pub": "AKTPtQgTod9YmglYtOauDkhXzVNJ+8EhcPypH56D614=" 282 | }, 283 | { 284 | "super": null, 285 | "priv": "KNq6QUo58pYpt3OxaS3M37tAy1NOniAWJjBgUyHLxno=", 286 | "pub": "1qX7VkUjSWuWwilF5mEwHccK7Rg3DdSY8YxBixNdGxU=" 287 | }, 288 | { 289 | "super": null, 290 | "priv": "2AiOdVDPnxDsI980QSYn4uFeNeWYomk5pslJ6eDSlX0=", 291 | "pub": "bwhyraVbliB/oYYXWlNSHYukeplNg0P+QkLVQ210l0w=" 292 | }, 293 | { 294 | "super": null, 295 | "priv": "aLgFfRmA1ksgt1o40o9AZzYvY4TSuEWvlVIUWARQMmU=", 296 | "pub": "dJK0Q0mbnnqKBf6qI+jRSNfwXLY+8NEFUOpsP1aY+B0=" 297 | }, 298 | { 299 | "super": null, 300 | "priv": "YOmj4TJZjRTPVtwoyEZyPmdCm8h3qFPLB6JVMIsPYn8=", 301 | "pub": "hFiT55IVAkuU4l1+OuOHAG0gpnIttFObNcS02dKAszs=" 302 | }, 303 | { 304 | "super": null, 305 | "priv": "UEIkDiClwOGI1nV5rva+UBIJ6vQkLmrH07hpBd5+Lns=", 306 | "pub": "2c2lyd9TyjBiugHiQHDXgl3zYDl1BaEVVudvMFFNRnY=" 307 | }, 308 | { 309 | "super": null, 310 | "priv": "ICF2tvbk9EXh/nIrN4L0SKhK8SpClkKY1EpBECwEF3g=", 311 | "pub": "O4HTQ2DtV6//pggghfZYGrU9JZMVUWB3MkFIk6za+jk=" 312 | }, 313 | { 314 | "super": null, 315 | "priv": "OHB8dLqZjDimShxk7YHdPa+FhJErtLdMsdOZtKEgMnU=", 316 | "pub": "fExCaufIbapm1NhBLWzHF/iutRhgT4Te6T2wW6PYKCA=" 317 | }, 318 | { 319 | "super": null, 320 | "priv": "qLUY3NTlcI5qDYGtE6wCrWs4zsYs53rHX3VvMBoZa1s=", 321 | "pub": "iDn3utdcZGenUC6WttzMnge8z4Ubhyw8IX8fAH32PWA=" 322 | }, 323 | { 324 | "super": null, 325 | "priv": "IGUsVH4RXJQzmhPyBovEz6pI9+3y2uQNZCPBj3E5t1Y=", 326 | "pub": "5GlGanM2DmjqQqSKf96lY6vlz6iuqKLgi+XWoACY3kI=" 327 | }, 328 | { 329 | "super": null, 330 | "priv": "6MDqbpBmuD/1qQarLGZfMCfYn2/btPrAWPRjcoLbcVQ=", 331 | "pub": "dNQGxrjSX8yZSeaAWLOq6izMxbndacClsCVHxRSQK04=" 332 | }, 333 | { 334 | "super": null, 335 | "priv": "4GDa2lj1uAieaPYnbfTfohIz9DuVxDMhYdXl/BiQvUc=", 336 | "pub": "iljmhBbrQOqnmAJUrbkX+XfZhWAdMqLpxelgRdSaLDw=" 337 | }, 338 | { 339 | "super": null, 340 | "priv": "8CpQvtUED2rV5l+727KSo1oIy3skrkgtaAt6cSy5/2g=", 341 | "pub": "apsFrkipLGK/WkSeDvGEWl/w37V2/LQNsBP9GgNSo14=" 342 | }, 343 | { 344 | "super": null, 345 | "priv": "KHyv9ibgNJqvLj5jCqo1H+8zuYhJaGnAk3XA87vZaks=", 346 | "pub": "DWgbhBTdYO7/5PcGzJ4TYF4rsYugwsKshTJULEN+jQY=" 347 | }, 348 | { 349 | "super": null, 350 | "priv": "uA68H83QW2ti5bXo7cQbuFJ9orrMkXUzpdK6Fv05+kM=", 351 | "pub": "KoSZQ8FM3FBoZ55YpEtSF3IvoJTQTNgYSVaqj3aCKWw=" 352 | }, 353 | { 354 | "super": null, 355 | "priv": "yOnEKN7bcwRrk/roH7k0OHonGYaUsA2TJoT5b+0YH0c=", 356 | "pub": "Gi/8r072I8+dfHaMipqie9Xq7PwZBiugzn8RepEksgk=" 357 | }, 358 | { 359 | "super": null, 360 | "priv": "gJil3gL0LBeaesq1aDa1rkTDfnATigVAqIm740mzmnw=", 361 | "pub": "Ufi0dc2YMLQh7lTWo0jTtl4kR4ZbCDsFhfuPFTJV2kQ=" 362 | }, 363 | { 364 | "super": null, 365 | "priv": "0O68EqUocIPI3bsBNhQnypBlm3vFGjq4RNOxLjjTVlw=", 366 | "pub": "2NPMLDU4vrFcFpaNUXIj3f0X/Bhz9HMan3WaPDUEaWc=" 367 | }, 368 | { 369 | "super": null, 370 | "priv": "gAjV4eGeAt+Zt5XAVvLEreoaF+cDWGpFVtHSop0T23I=", 371 | "pub": "m2cBczvCOIFucdHVPp5uQCUVH2VmdNkIk/3BjjHNaHk=" 372 | } 373 | ], 374 | "hidden_otpks": [ 375 | { 376 | "super": null, 377 | "priv": "IIL9xrH5TjTXfnthpTM6mfWt6ZmP3r3mO8jc2BTf4EE=", 378 | "pub": "ea1Wf2VjSuE2lOf5M9H9gpVNs5kcQCepmq3yCxyy9D4=" 379 | }, 380 | { 381 | "super": null, 382 | "priv": "iOrTCzX0HV2j5GwthwHMQi8AEM/ccfxCLkxPnQx4cGs=", 383 | "pub": "Gm8o/+mEhRTwo6SE4hsNyan8kNrQu8RRH4qNdpyfi1s=" 384 | }, 385 | { 386 | "super": null, 387 | "priv": "MPxv4CIdqFc3yhBvbjyePzgkvd9Js9ztDtv6H7HhNXU=", 388 | "pub": "+bjR2JUBtNaoglUzSKE4WS4ZXDPiSOYidlRoDGD2LlM=" 389 | }, 390 | { 391 | "super": null, 392 | "priv": "GFtMkbHY5MntJDRDJzSivNhedLU+lvzQMdmGPE+aUlA=", 393 | "pub": "DowEKSPIqWcFIic+KYyYV3CEpYxxw+NlbKl3azUJHHo=" 394 | }, 395 | { 396 | "super": null, 397 | "priv": "UEdDteqr43RAMyXdLB5AOJt3tP/yfTxNrp0oJp/610c=", 398 | "pub": "OTKV7uqlbpDdF7sSDB5JcVEZrniH64nQgPuUwscAOgk=" 399 | }, 400 | { 401 | "super": null, 402 | "priv": "8PGBzN+KbOcyRGXQb3bX1afZVSVARY85hjCG7aiYLnQ=", 403 | "pub": "Vkx+jZggXmXFMZOj2+f3QMF4Kh+5eYdpQUCMAII9gCQ=" 404 | } 405 | ] 406 | } 407 | -------------------------------------------------------------------------------- /tests/migration_data/state-bob-pre-stable.json: -------------------------------------------------------------------------------- 1 | {"changed": true, "ik": {"super": null, "priv": "QIjZ0TyJQvxUg3/KKvBC2r3cwM4+WL66GjJyfocB+1A=", "pub": "QZDNTFGAfVS/c2T551G1pJVI2lfzG/Lm6ybnWBBvmx4="}, "spk": {"key": {"super": null, "priv": "+EL1cKs4EPwX5qyEYJ68nukAssiGRkQ7CAN901yAa2Y=", "pub": "rPFiK18xLb9Qt3z8PO5i8mHdG5bpXipxz1qC4VaODnU="}, "signature": "9Vbsa+Li0454FyH5NQlg/2iuUV8XWe1BThzCk31vF3bQJeng6I0RsViB9E/+z5dBBN81lGkHxx06JdcveUogCw==", "timestamp": 1585838838.1353922}, "otpks": [{"super": null, "priv": "mF/hazPE/rPIVEtBM7ygnr7wLbDxeJIgV0hkaWwSsGo=", "pub": "iG2A4I4ZlwDK5ZwBwEvdmKl9twB7xJCZFRQdweU3/l8="}, {"super": null, "priv": "IKOkSt0Bh0Ka0gHD0TV7QVc2uL8csIwRTjvEQXqXVUI=", "pub": "XznFTWpUWVn4tn90Cu73PoRmMEvh/X2cu7GogSF2m34="}, {"super": null, "priv": "uJu7FqNpzVxkGxx6eZWlod1QpG2tPyPBu17E54tHuXI=", "pub": "diPMDYbquHxQLgqKdEdJOEqwZW4FxhXg60HHv156QjY="}, {"super": null, "priv": "wHgVeA/gR3aT/VdCa0bwLJxTKkxbyL/0c4/1NnPbGWI=", "pub": "2zT6HdkeRtvSkZbQfqdwSdOEbBgwh87oU/VSSx3h/ww="}, {"super": null, "priv": "CNVCXvIIiORItn0Kkap5xKIVmqreF4qoyNETBumzvlI=", "pub": "cYsiHts9j969+f74lIp0DqvPn4qTVVT/NInQMs4LrHs="}, {"super": null, "priv": "kOVplsJFNIuOuzZYO0XYgik1yWZFo7xPkx+j9OkUZV4=", "pub": "VT88GDQaPHkEuSji1Hia54ERKXtHKJ+KMv/5ySLTIFM="}, {"super": null, "priv": "QNb/Gzw7xFolZ1yCV06bHY32JVNWF7nKwsUUoVzb7kA=", "pub": "Hs98Ki6ag4mlt5tZ+9r+l9nm2Gw6SNTeJwHMAPUMvxE="}, {"super": null, "priv": "IB+DFXTj2sMQuPlJq2hJQiAJkTjUFIRia/ybd4eS0U4=", "pub": "/Uo5APBlwfth/jHV1yRcSAk4rMBpEp6dfksvtgdduxk="}, {"super": null, "priv": "+Oabh5JV7Oh3iqPcLmOfGoq05lxiqc48p6kYyfu9fG8=", "pub": "YRAJ0m+HhjOdk36JrOWNzd2nMqPhiP23lSN32DwbZmU="}, {"super": null, "priv": "YFedd8ovB/E6aPr0JLQGARs8SIjPAJBNBYh9D2abZUk=", "pub": "oO4rt/v6GeD1qhzqivma8WwxRNb6bahOReUrK4rOAUc="}, {"super": null, "priv": "ICYuRB5T9wN197pkTev0UKMKytVYZr4IJn6bilR+E3U=", "pub": "00fLy0h+ywQfM5ZRMrGILOfdM+mDtEVtDzsRO/uhqVw="}, {"super": null, "priv": "8MeCJyqfGPO70ivDibwIMwnsIATl/ecLx8uUCKaBD0o=", "pub": "zhOQmHOy0LT3jcOHBMTshnjW+RQqINfeyS1zcDnooiY="}, {"super": null, "priv": "UPSnegykk75IWAgbqLI4f49x1tc2PhlQxprHUAjqumw=", "pub": "8xrWxkOLZv0m4aX3H9qAzrkuRqWvNIrrpmuj276O9H4="}, {"super": null, "priv": "mCJmQt0bShBSsvWvazlu3iRwrK2wxDDEyG8FbFK3lkE=", "pub": "UJ7cLNKlW0R1Cw86IKQe2kTc42R42968/WIBOsfkTyk="}, {"super": null, "priv": "2PqrVAwWfTivUV1EJyiWfi+XnaWT4xsfP9CzLZSdnUQ=", "pub": "78DyNyuB1kZLM3+HA4kjZEYOSIRFC4gsNJrGKs88nmQ="}, {"super": null, "priv": "+Kl9g5W31XAQNYM5k0Ea0syGnw3Cfg7vfx26HiIg10o=", "pub": "qKHzmNmzAp3rGtP9kiTnbbAR8EPAa9VwNVBpR8pI0w8="}, {"super": null, "priv": "6ONph4+mTVH7yEDjKwytXmHXd5ozmJXIbcBNxJqBvWo=", "pub": "QpjCOKIPYGl2Dhdg8K7ALkcItk9sfXetu39l6ofeiAE="}, {"super": null, "priv": "ULmXw1P2C6UTmuIHCTXVMGKkyEqgtB0a6eqf00Vgm1Y=", "pub": "WAzhk/dpLLld3A8MT5xTvK97y5ctyQCLzU65chw3VGU="}, {"super": null, "priv": "iCzC/VFMlwOVojmrphpES6BTPivTVR3LxOJjSsLZw2Y=", "pub": "G0zzGeHaZ0URWEpvgOGuOSOKyxTQJ/+vNjXdLowTIVk="}, {"super": null, "priv": "KK7xY8WR3kTz3Y0Q6V9dwslfdUIdmZyuWVmkFwz3ml0=", "pub": "U+fnp1nzpti9jjIlsOWfaf/xELoGzSaBS0j7K+igPTA="}, {"super": null, "priv": "+IzFOmXdyDHbVa1hz0IoBMa3+6IICBpChXe5BLwboXY=", "pub": "NM3me35aQQt0hYpKqLegbPTSP2+1sijlhZ+61CQsghs="}, {"super": null, "priv": "SKEGHORZbizg+pSSJC6dvw5I8/+hyLOLWeOd+EF3Mkw=", "pub": "ZRYn7uj96npVZ+G1HI8AMVkn35MUpN7E2+xlSqQjP1k="}, {"super": null, "priv": "OHZj5PFuf36yJmlVUWXhX1haG6UlpAjerYO2D+TsD2g=", "pub": "a1r+sq48G02mK+sQM3b/wXbJNneZBtIuIDdCuB355wM="}, {"super": null, "priv": "APu9snHIF2hBIxdHc9CF/sqBRv7w54HPWm8RBzlfhlQ=", "pub": "wrk7iWdsC/mXY+Y+HO4//YfqTNIK2DRlNzrRfaSp+Ag="}, {"super": null, "priv": "aITj4hEwcf77+J/8sI/vKEeYIRljfmDQCLgz8khVCGw=", "pub": "ghKyM9jjCn0WndkgG3TOb/AuQXJodZqFEZwk7wa6sFY="}, {"super": null, "priv": "GHux9BCARx2r+yxyWgaqivpZfp0wREEfGJTtKMMktFc=", "pub": "oGrGvvrMNYC/5+Pavf0UstjehfNG0ycSbrXweh+/bRs="}, {"super": null, "priv": "qJdUjWIYo24tu9axt8aph0cTYkr87RXEKoLI8IIvUHQ=", "pub": "vHIstvN/icVTJFx359XcAiaR4eJ0OF1/awSgao/lFX0="}, {"super": null, "priv": "QAn92t6ElvFZD17+F9Ty+y8AwNJbGeZZ+9LmGoFLW1o=", "pub": "f38jnZP1Ahxe+h5e6kkUOEiG8WEFYhsxGfZA7UTpUjg="}, {"super": null, "priv": "WP++BoXmG+bh5MVvADfhOguyHqAYkAPMjUX5qRNuukU=", "pub": "oHNK1Lp6D2WggNszoQVmLqHngKhSv72W41oimQ9txlg="}, {"super": null, "priv": "+Au1olgzBT/XP7oNkbGi/Q7yc8jmzrbW5uBwU3roC3E=", "pub": "yf2bdpHLgYfjsi7NLDWjG9HdiY6j8u7fWQgpZN6oYRk="}, {"super": null, "priv": "KPIFIehACau2vLC6WTc/v/nV25MPMTEXwoBcQba5rHY=", "pub": "0RJPk9aCbuXeSkGp7Qp3QrNVkv97Lg0LWCNrCnV4rns="}, {"super": null, "priv": "KDWIhD7ArwoN4PlKpCq6ToYmkfts9rsqgTVNoIMfqH4=", "pub": "u1vII8OsNpQ3VYme36NS115vkkm52a73l0+KaMsMHmg="}, {"super": null, "priv": "sOLOWJCrIW0o+z9Z90cnVYRc29bMRzMHhii/jwZqaHg=", "pub": "4Sod6pqfjXCtwKiSJ2E3DzB5FTFVpmf+UeU37Y1JV1A="}, {"super": null, "priv": "0KjQhxqvObajKS2vd24xwy6ReZ0lb46g5Kv+tC8rLkg=", "pub": "kEynmh8wDYBkoZ22u8IIKNm8NDd/6vZHlrmZsHDbZzE="}, {"super": null, "priv": "aDRWuaEGVstHh6GL0wcVPFHKt/qMbfinBkTMdDAnOno=", "pub": "sDpzr/U673ocG+Nrb/y4+5RhEilIeQoP4SIpYiYC1CA="}, {"super": null, "priv": "KO0eL1NCKjqLoCHdA0ywIUFNxs2O1zNSBEow19Bn8k4=", "pub": "Ohob+FgEjY+ccigu9SHlShy2MauwB5vBXnfIBjRvj2s="}, {"super": null, "priv": "yLa9+KuxOGnKTqyQzjvC7LTIeFXXCMseUIX9Yd6McHk=", "pub": "frLXrEErkBpukFFXDStv1ugjr3jarAm+NeuI6TvuWk8="}, {"super": null, "priv": "mIQp5snCOdUWk1udaRCNBJ6ulqkjt3AX8tEihn8kan0=", "pub": "6yYhV07BM33CcOVAYDCUtsuPWXKbx6j7qe29GUcJrmI="}, {"super": null, "priv": "SO2CV5SDgq0jJL9liZr/EPnq5A/5lMOmJszYfzEI9mc=", "pub": "KBUBgrtdo3U386Q/72ApP7Qkgh+F/Rg2EelL6aiUJ0U="}, {"super": null, "priv": "IN76iNyzJ8soOUWiEwupVZyg2f8+/8FX/KC1M2vzAFk=", "pub": "D1RP009s3GHHYj00HYv9CH42B4TIBQO2uIF7Oep6DHY="}, {"super": null, "priv": "MDIJYjSzwslr0lyCu8lt947i/1SEJgGy0A3sP0HCukE=", "pub": "vpN9qRq+oRIMSFwU1v2ZTXG2vwDSwhAPTHnsJbPNeQ4="}, {"super": null, "priv": "0GZodq/ZbXfgi2KLhePHgLwwxrGtxnIM9Xww6bK5MXk=", "pub": "sBeWGWeGSKYYmD9VWGP86PVGhAE1Z2Sb/a5tvrE+PDI="}, {"super": null, "priv": "MFUY8Lrx4C5/Bv9WQPAfGKkPx0Ymh230KHn9ov5sPlQ=", "pub": "pfrdkm+cbU5gQndm3bGVW8nkKVCAQObqkZikauMkZmI="}, {"super": null, "priv": "mJ+YtZEuwz1vCrsVge1PA4ziA1a8KocFCthOvpFWDlI=", "pub": "90mHJRGTGtgcQP+oBohfBhLdiDEcltnWmbLZRYQr6wA="}, {"super": null, "priv": "uLrKXJS1KH9KithKnQj6kKPb2mzRITyS/mu+y6gNYHI=", "pub": "oHO9+8GX60py+2aMa4q8g/OhnYsBMhmLOdsyp+f/I0M="}, {"super": null, "priv": "gBv2SIIA0NyZhBZw3noVhmIJlVdotfaWgRE5DRBfPEs=", "pub": "FyStW81WtKImB/ZvR/u84XQj4PTApuw/ycKK0uumzTA="}, {"super": null, "priv": "iDRjmPxBcjdasEspdhfAN8yBGp+2ftIjg2wUhKpb/ko=", "pub": "21+oYUhB+hdz3aSb8+gsyGwN8AfqVgJNkGTMvebRg2Q="}, {"super": null, "priv": "aKOrTedMmYNImd8XKsuZpmfgEtbrGoHSERMn2sdSv3U=", "pub": "w5VlNkhn0xs1JvAZKsSOEWsIbOuxG89/QHkkZ1lrVnY="}, {"super": null, "priv": "eDi+EdqCi7sxxJyit44jfNrz2NTXtcA2HkZVpINYK2E=", "pub": "j9yEBSpYB1TYDnJV9K6awuLV/UNpB1BYByTfps3m9j0="}, {"super": null, "priv": "KDswEYoGQ7AQp55yZcZnJI9ZFQHZQO1zl8NBFdw1tlg=", "pub": "kJY2IPqnD/mWt8WC5QNxKBn64sY7VQZEVGuUzWIcoFU="}, {"super": null, "priv": "EFs4ogDHUtT3ZimmqlyU5AZ82pVBecgB2xTT0aLQQWg=", "pub": "DD+t2nhe7sshqt5NQdZoQunh7uTaSe5R5kYziNpkPx8="}, {"super": null, "priv": "uLQwU1vptJkQxCmidPQOBgc72VvLFpMtzwVVgLdy0Go=", "pub": "pnWFf5qZMq4wSkXde/xv/HueDPUPQtkf5bZP7YWNzBc="}, {"super": null, "priv": "iMoEtlVq519TgEKsdGveczhsQkuyik6nAzqqm7AEi1E=", "pub": "hSrL0yE3ti8mdvBzMbsuzROOxCaoSGkKSAdZXWfzyzQ="}, {"super": null, "priv": "YCGHEULqMWHNWyAvfmt1hvVuGyFyX0WObZJgs5rbXm0=", "pub": "QLzE/2quERlUAdvDY35Xqwz4ZhEZScRwUvu0sodgO2w="}, {"super": null, "priv": "GAcRAAAPyetmy1sAAXEms/g0tMGu2gI9VuwZMg3tNkw=", "pub": "myNirgtQ0dEBGVjtk7s9gNj1/ZXux+HgAC01qwyxIlA="}, {"super": null, "priv": "YBr6OuTilNkeYtTBPTUY5vU9azbQMrM7eQEFeAaBeFY=", "pub": "wsEBGlhlyRi7ObGaFMqe7k3jGp6mqmFhMpGgZvgikW4="}, {"super": null, "priv": "aHsFpwK8mtBHXbclcut8336sqkJQ7wEuOp2YFYUE3G4=", "pub": "pJIZuSLK8hoYGw/xAj8MkxolDXAXaZfd//y8Nw6iK0k="}, {"super": null, "priv": "8JDU3KH/lyWo51j1TR1mR/3sQzPMkNNR/7+CjKffr08=", "pub": "Jl2qcGXcCcNmYhRHWgD2STGpK5IvO4JkvEGKSTiWMR4="}, {"super": null, "priv": "gGzEHtFqKYgL6q4ecAEVBPuOZQDdTHoR/fYCzR29v3g=", "pub": "BvSavZCmWz8KBonUQbBJVcOgBmInmxyJ22C4rDFzCTI="}, {"super": null, "priv": "0KwWWa0gmY3QF4MM7f4G7i/FhtAlCkZK/K+1ckKQWXo=", "pub": "tzryE/rtVgK1GMbE0zjSxdo2dHb++c0cTGRisvV2A0c="}, {"super": null, "priv": "SHLqvrhfkAxPbeLzo+9COAj4DOH6htHf8YvuJ3pPJWk=", "pub": "J4GqWoJ/C4xZCWrPk/EMUd8I/VyTbMa4J1LmjMv61WY="}, {"super": null, "priv": "sMghJ3oCeDp6sIh48tGaHvL0u7H8aH4HzgzxfUzjHEM=", "pub": "PVduyUyRBtLywH1RIV2E+omo9ElxEvIBNFvCxav3VUg="}, {"super": null, "priv": "cBgdUWc2gaL8al+VPvpTLSKAsksWemUA5BZwwoVB3Vc=", "pub": "lT1ZpgYFFhBdrvHoohhmdWw6FUVVQrC8V0MMoOAIxDM="}, {"super": null, "priv": "YIXhHHKuW6oDHronDjNdi+Iq883Hqy5xdCfX5Y7ya1A=", "pub": "/GQzME6UHPUZ3YKf43wjgYyKehsQAFaWQvxNcMjV0BU="}, {"super": null, "priv": "wC3xvutR7oEcq1t0xRH34wnjNkplfLb9h4T5flFgu0Y=", "pub": "4zcY5DlCW5XHS+mtCYAnZLuneJ5c0vpU0vJ+ZQHw/F0="}, {"super": null, "priv": "uNymaNekKHaWuD+vKaNalg/mBaZzYSZ1xZOjTFQffVk=", "pub": "CvoRo83AxsOs+RQ1aGPyDlDlxoXgiGkbkmbEKG+UR28="}, {"super": null, "priv": "sFCsyA/fEl+jYEOgB/4EaAK41bKGemXzu1er3YA7pUU=", "pub": "IzssUP1NfX6sLbF96t5baFUrg1rzoeGoJebzaRWS0Q0="}, {"super": null, "priv": "GI0NMPf84DDJRW9OyGHQRaF8snbnaIe/NUqwdYMgzUU=", "pub": "Y/XFwWC4zfQslZ5MaUf9KAbYAvxG+Nol+uJoy7fN5iU="}, {"super": null, "priv": "SInnia5ylHP4OWSzEVhKRDSTfEt7TRQRBDclQ4O5YXA=", "pub": "NgCe+U/mtvSblG1lGm0ihYYWaTwv90J03jFy1n4Y/gQ="}, {"super": null, "priv": "MGOOn23XEYYJlvwQ5TzEuk7DafcAXuq78WeND06zz1M=", "pub": "XuJ9jkRFZq+YUvPvNehfuYzKRJyUApHOxm5QMTyWY2U="}, {"super": null, "priv": "KNXP5NUwMgyYBNl9AvV6vgwKfauv4mVMbuutlzSN83o=", "pub": "zS7oYaEHVprUuCrPKwhtFnsbgVtIZcRLMgPv7wUIjFk="}, {"super": null, "priv": "CLhh8nz9WvxP26Wl8h7nBkDoBERuhR2AMtbbjmFk10w=", "pub": "Fjm+eajuYVO4cM+0VtXDc+L+zvaYOvWgo5ZyZ8FYiWk="}, {"super": null, "priv": "wH4Rv/cbD+N7JyeT6V1upB9gUnNK0e+bVmfRfC5e51U=", "pub": "R2jGvEEKwta4U7+xJ5z5XkcEdY2qFDm+7vUoYbXat0U="}, {"super": null, "priv": "WC4gjv6PK84S7/t6BoysxLyKzCRWXfYDkfy/adD300U=", "pub": "MItfK2tn0HYinHDbiRTqYATG5rQt2JuGasMA+TWBSSE="}, {"super": null, "priv": "IKcSGbL6m2iiELSC1jegxHq5+Ibw8pAJTXqraiPOMXs=", "pub": "bytPx05Qpq/OHA7Jjt0qhH6SWs7So0Kvep+4eNA3lBk="}, {"super": null, "priv": "EIgJkc8QNvwcVB9Si5vQSKmGnrdTskPH95C1P02620E=", "pub": "sE3dBXOt+Jsjn097hOT4tiZHwpbAk1TEu6Ct4BmApSg="}, {"super": null, "priv": "mKotVJLygKk0ZTq05gzSypfHc553ckf2OSN0IxlxlWo=", "pub": "drxSli4aUWhFOiGfkdX+M9zG5WX+XLODx0V1evQEVhw="}, {"super": null, "priv": "iEyzsDgjE/nyGn68Fi+pu9otW0soXArIxTmn60nRDXM=", "pub": "5/a+7r5OH/SiguydhaGjJtOv3/EpFJSNUvzlrXSVkTQ="}, {"super": null, "priv": "gBCW/88aUuG5IHsH0yTY4sRXaxBRiNaFZTWTzUeD8Uw=", "pub": "UtvFwfwX4/Gw7YiFwU8ilN0PzPaPm2/hEpHKCRIb2VI="}, {"super": null, "priv": "WHtnrAMCSePE3D5xuLdCFgBnUpiOR7ha7ECWCJ/D90E=", "pub": "qjMYYa5m8OTgvaiqc3v2LwMFa9CLyCnoJGfaA8fqvVg="}, {"super": null, "priv": "OABznVf7Rf20Y64DwIBjXB4pYz/TziCxp2M0BYo8H1o=", "pub": "JpoUwT8+tDSuNQB707N7huXHpiSQ3MV7l0UhYDZP/kQ="}, {"super": null, "priv": "kNt6SJYizu+PuHXwZ2O/w/M+ATj8l1+hBVZVdPrzOXc=", "pub": "vy8l+oZo8VXaqeg3f38uapvi4U34bIOUWSUyD8VziQg="}, {"super": null, "priv": "UHM3C6HjzTdoXSrbUmHEVY5p+IreIOVvs7Hovymk20c=", "pub": "pvt51BR3DfRJLcNYtNlaUI4g5gkyj1xaSxxRgLqucQE="}, {"super": null, "priv": "4JuJJ1v0zBiwUOOZBf1oiXdH4RBZfTP0FUNPxq8IOGs=", "pub": "jVctR5ZgOVj2lv2C/viDAAwIAu9duClBbt73dLXpPCg="}, {"super": null, "priv": "SMhOpb2BhO+WkOETtJJcUw8e9WDEjBQUNuw2tXiZCFQ=", "pub": "0647Yw0xWXTeFinU1z0+2hXlgeCi+SUg5jfmfoJwfyg="}, {"super": null, "priv": "CHs8fr7eiZxqZAFpUQuSIxldRA5wabdAEbV2NzlVs0U=", "pub": "aQ/KUArFGKL83e1V1adcY0T99QdDVe69YGPfr5APYGw="}, {"super": null, "priv": "sCxvMfqQh5ek0hHC13vD1+eHp0FT6Jqes4IOAADjYHU=", "pub": "P9xMk49btG6JHym+du/1eJVN3iK6LZWbPkahAAy3TEw="}, {"super": null, "priv": "wPYaVsgtruGdmRmnI/jw9D+oEQ2LNAHYSiTeYB4MjWU=", "pub": "pKv0K6svY7nx56c7GlbcBNMxODMs8fzY428+gnaTYj4="}, {"super": null, "priv": "qOsV6r7KXWal0FJKdjgyDDTLlkB7Suh5bGV8xRP5/mE=", "pub": "v8ZsyQvTghPtst5Hb/Uq8fenSvvQmyd2L9xd7tKCm0I="}, {"super": null, "priv": "INSMVWk7y6MU4y/5Wv64yqj7BRXEIurM0FPWk4uDYlM=", "pub": "VRAUx8TfLyD8mCO/rgGSFtfU5F4zoiMdzHHlN85gX2A="}, {"super": null, "priv": "gCFonOh0560tDjy0K7IXqOIiu24ttVlFd7bJXP5YSFE=", "pub": "fFJY5oIl7l27ZbZIHh5XZwlhZ5PfkHAH2BF0k32YzCA="}, {"super": null, "priv": "8Goi1zhWMzf4roULWCi9WURLl2A1RiP0r1LWyaurVnA=", "pub": "NzYsa9yteQCnqxNKXtY7ngS45BQL3xanTXGjXW5zvww="}, {"super": null, "priv": "QDY8p2wGSw6olkhm7v2ATComTmaWEBsTIxbDYz/La2I=", "pub": "YccRAEKCHKHcZzUgG6IOq3P5BWXkXOz7ELSIXR1mE3U="}, {"super": null, "priv": "OIGeAOEY9iWhNgNmxaVWTGqpzEIJOti7F0cGGVzU/1s=", "pub": "M3N88ntmyEtlBjV5oieJqf1zNoZ9nf6K9zI5b8JfIjY="}, {"super": null, "priv": "WArUzQl4UD+N75KnkK/e53PzljhCIaKxCNZz/RCyT1s=", "pub": "y5+zPBZFMjPy0YOWE0b6jlnGJ9MuSwdVRhLjxVAIjRc="}, {"super": null, "priv": "KLJNoEJDk8mAWoz73EOkiYk2YweEcSvyM51y9E5sFnw=", "pub": "0rCteVTcJwZQEFqaI36siYzd74c/z1SIjALO99vUUnw="}, {"super": null, "priv": "SF5gTDcLY8IYl+biLGkWFFshIVutz0sjPu3/ZPsk1ko=", "pub": "X47WOcnRGmt2WcARfGva7+fB7OUmgqSyob63vkwEZ3Y="}, {"super": null, "priv": "gGI8m8Fncstde+nBqI1pT5ipoq+ahBuP584qznbi2l8=", "pub": "rRyZt5e/V1DfAmgINCtGQoQUjvbkoaGnbFxlRy/Z1yk="}, {"super": null, "priv": "oHyO/vWuxasO7YUjsFmeyiM9jWOMKB5wMPTXILfr+WY=", "pub": "Z6ztqWZqSmnf0idfkMmFp0CGRDOgWKYbW4cdQFoJsTs="}, {"super": null, "priv": "wK4en2gHcrn57VonzKSzaxtHtYHJKbmDxN8kI7oxH1Q=", "pub": "xAanHhFG511BVJdct2OBEoJXggyi28gMJYTTJ64fGjA="}], "hidden_otpks": []} -------------------------------------------------------------------------------- /tests/test_x3dh.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | import os 4 | import random 5 | import time 6 | from typing import Any, Dict, Iterator, List, Optional, Type, Union 7 | from unittest import mock 8 | 9 | import x3dh 10 | 11 | 12 | __all__ = [ 13 | "test_configuration", 14 | "test_key_agreements", 15 | "test_migrations", 16 | "test_old_signed_pre_key", 17 | "test_pre_key_availability", 18 | "test_pre_key_refill", 19 | "test_serialization", 20 | "test_signed_pre_key_rotation", 21 | "test_signed_pre_key_signature_verification" 22 | ] 23 | 24 | 25 | try: 26 | import pytest 27 | except ImportError: 28 | pass 29 | else: 30 | pytestmark = pytest.mark.asyncio 31 | 32 | 33 | def flip_random_bit(data: bytes, exclude_msb: bool = False) -> bytes: 34 | """ 35 | Flip a random bit in a byte array. 36 | 37 | Args: 38 | data: The byte array to flip a random bit in. 39 | exclude_msb: Whether the most significant bit of the byte array should be excluded from the random 40 | selection. See note below. 41 | 42 | For Curve25519, the most significant bit of the public key is always cleared/ignored, as per RFC 7748 (on 43 | page 7). Thus, a bit flip of that bit does not make the signature verification fail, because the bit flip 44 | is ignored. The `exclude_msb` parameter can be used to disallow the bit flip to appear on the most 45 | significant bit and should be set when working with Curve25519 public keys. 46 | 47 | Returns: 48 | The data with a random bit flipped. 49 | """ 50 | 51 | while True: 52 | modify_byte = random.randrange(len(data)) 53 | modify_bit = random.randrange(8) 54 | 55 | # If the most significant bit was randomly chosen and `exclude_msb` is set, choose again. 56 | if not (exclude_msb and modify_byte == len(data) - 1 and modify_bit == 7): 57 | break 58 | 59 | data_mut = bytearray(data) 60 | data_mut[modify_byte] ^= 1 << modify_bit 61 | return bytes(data_mut) 62 | 63 | 64 | bundles: Dict[bytes, x3dh.Bundle] = {} 65 | 66 | 67 | class ExampleState(x3dh.State): 68 | """ 69 | A state implementation for testing, which simulates bundle uploads by storing them in a global variable, 70 | and does some fancy public key encoding. 71 | """ 72 | 73 | def _publish_bundle(self, bundle: x3dh.Bundle) -> None: 74 | bundles[bundle.identity_key] = bundle 75 | 76 | @staticmethod 77 | def _encode_public_key(key_format: x3dh.IdentityKeyFormat, pub: bytes) -> bytes: 78 | return b"\x42" + pub + b"\x13\x37" + key_format.value.encode("ASCII") 79 | 80 | 81 | def get_bundle(state: ExampleState) -> x3dh.Bundle: 82 | """ 83 | Retrieve a bundle from the simulated server. 84 | 85 | Args: 86 | state: The state to retrieve the bundle for. 87 | 88 | Returns: 89 | The bundle. 90 | 91 | Raises: 92 | AssertionError: if the bundle was never "uploaded". 93 | """ 94 | 95 | if state.bundle.identity_key in bundles: 96 | return bundles[state.bundle.identity_key] 97 | assert False 98 | 99 | 100 | def create_state(state_settings: Dict[str, Any]) -> ExampleState: 101 | """ 102 | Create an :class:`ExampleState` and make sure the state creation worked as expected. 103 | 104 | Args: 105 | state_settings: Arguments to pass to :meth:`ExampleState.create`. 106 | 107 | Returns: 108 | The state. 109 | 110 | Raises: 111 | AssertionError: in case of failure. 112 | """ 113 | 114 | exc: Optional[BaseException] = None 115 | state: Optional[ExampleState] = None 116 | try: 117 | state = ExampleState.create(**state_settings) 118 | except BaseException as e: # pylint: disable=broad-except 119 | exc = e 120 | assert exc is None 121 | assert state is not None 122 | get_bundle(state) 123 | 124 | return state 125 | 126 | 127 | def create_state_expect( 128 | state_settings: Dict[str, Any], 129 | expected_exception: Type[BaseException], 130 | expected_message: Union[str, List[str]] 131 | ) -> None: 132 | """ 133 | Create an :class:`ExampleState`, but expect an exception to be raised during creation.. 134 | 135 | Args: 136 | state_settings: Arguments to pass to :meth:`ExampleState.create`. 137 | expected_exception: The exception type expected to be raised. 138 | expected_message: The message expected to be raised, or a list of message snippets that should be part 139 | of the exception message. 140 | 141 | Raises: 142 | AssertionError: in case of failure. 143 | """ 144 | 145 | exc: Optional[BaseException] = None 146 | state: Optional[ExampleState] = None 147 | try: 148 | state = ExampleState.create(**state_settings) 149 | except BaseException as e: # pylint: disable=broad-except 150 | exc = e 151 | assert state is None 152 | 153 | assert isinstance(exc, expected_exception) 154 | if not isinstance(expected_message, list): 155 | expected_message = [ expected_message ] 156 | for expected_message_snippet in expected_message: 157 | assert expected_message_snippet in str(exc) 158 | 159 | 160 | def generate_settings( 161 | info: bytes, 162 | signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60, 163 | pre_key_refill_threshold: int = 25, 164 | pre_key_refill_target: int = 100 165 | ) -> Iterator[Dict[str, Any]]: 166 | """ 167 | Generate state creation arguments. 168 | 169 | Args: 170 | info: The info to use constantly. 171 | signed_pre_key_rotation_period: The signed pre key rotation period to use constantly. 172 | pre_key_refill_threshold: The pre key refill threshold to use constantly. 173 | pre_key_refill_target. The pre key refill target to use constantly. 174 | 175 | Returns: 176 | An iterator which yields a set of state creation arguments, returning all valid combinations of 177 | identity key format and hash function with the given constant values. 178 | """ 179 | 180 | for identity_key_format in [ x3dh.IdentityKeyFormat.CURVE_25519, x3dh.IdentityKeyFormat.ED_25519 ]: 181 | for hash_function in [ x3dh.HashFunction.SHA_256, x3dh.HashFunction.SHA_512 ]: 182 | state_settings: Dict[str, Any] = { 183 | "identity_key_format": identity_key_format, 184 | "hash_function": hash_function, 185 | "info": info, 186 | "signed_pre_key_rotation_period": signed_pre_key_rotation_period, 187 | "pre_key_refill_threshold": pre_key_refill_threshold, 188 | "pre_key_refill_target": pre_key_refill_target 189 | } 190 | 191 | yield state_settings 192 | 193 | 194 | async def test_key_agreements() -> None: 195 | """ 196 | Test the general key agreement functionality. 197 | """ 198 | 199 | for state_settings in generate_settings("test_key_agreements".encode("ASCII")): 200 | state_a = create_state(state_settings) 201 | state_b = create_state(state_settings) 202 | 203 | # Store the current bundles 204 | bundle_a_before = get_bundle(state_a) 205 | bundle_b_before = get_bundle(state_b) 206 | 207 | # Perform the first, active half of the key agreement 208 | shared_secret_active, associated_data_active, header = await state_a.get_shared_secret_active( 209 | bundle_b_before, 210 | "ad appendix".encode("ASCII") 211 | ) 212 | 213 | # Perform the second, passive half of the key agreement 214 | shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive( 215 | header, 216 | "ad appendix".encode("ASCII") 217 | ) 218 | 219 | # Store the current bundles 220 | bundle_a_after = get_bundle(state_a) 221 | bundle_b_after = get_bundle(state_b) 222 | 223 | # The bundle of the active party should remain unmodified: 224 | assert bundle_a_after == bundle_a_before 225 | 226 | # The bundle of the passive party should have been modified and published again: 227 | assert bundle_b_after != bundle_b_before 228 | 229 | # To be exact, only one pre key should have been removed from the bundle: 230 | assert bundle_b_after.identity_key == bundle_b_before.identity_key 231 | assert bundle_b_after.signed_pre_key == bundle_b_before.signed_pre_key 232 | assert bundle_b_after.signed_pre_key_sig == bundle_b_before.signed_pre_key_sig 233 | assert len(bundle_b_after.pre_keys) == len(bundle_b_before.pre_keys) - 1 234 | assert all(pre_key in bundle_b_before.pre_keys for pre_key in bundle_b_after.pre_keys) 235 | 236 | # Both parties should have derived the same shared secret and built the same 237 | # associated data: 238 | assert shared_secret_active == shared_secret_passive 239 | assert associated_data_active == associated_data_passive 240 | 241 | # It should not be possible to accept the same header again: 242 | try: 243 | await state_b.get_shared_secret_passive( 244 | header, 245 | "ad appendix".encode("ASCII") 246 | ) 247 | assert False 248 | except x3dh.KeyAgreementException as e: 249 | assert "pre key" in str(e) 250 | assert "not available" in str(e) 251 | 252 | # If the key agreement does not use a pre key, it should be possible to accept the header 253 | # multiple times: 254 | bundle_b = get_bundle(state_b) 255 | bundle_b = x3dh.Bundle( 256 | identity_key=bundle_b.identity_key, 257 | signed_pre_key=bundle_b.signed_pre_key, 258 | signed_pre_key_sig=bundle_b.signed_pre_key_sig, 259 | pre_keys=frozenset() 260 | ) 261 | 262 | shared_secret_active, associated_data_active, header = await state_a.get_shared_secret_active( 263 | bundle_b, 264 | require_pre_key=False 265 | ) 266 | 267 | shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive( 268 | header, 269 | require_pre_key=False 270 | ) 271 | assert shared_secret_active == shared_secret_passive 272 | assert associated_data_active == associated_data_passive 273 | 274 | shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive( 275 | header, 276 | require_pre_key=False 277 | ) 278 | assert shared_secret_active == shared_secret_passive 279 | assert associated_data_active == associated_data_passive 280 | 281 | 282 | async def test_configuration() -> None: 283 | """ 284 | Test whether incorrect argument values are rejected correctly. 285 | """ 286 | 287 | for state_settings in generate_settings("test_configuration".encode("ASCII")): 288 | # Before destorying the settings, make sure that the state could be created like that: 289 | create_state(state_settings) 290 | 291 | state_settings["info"] = "test_configuration".encode("ASCII") 292 | 293 | # Pass an invalid timeout for the signed pre key 294 | state_settings["signed_pre_key_rotation_period"] = 0 295 | create_state_expect(state_settings, ValueError, "signed_pre_key_rotation_period") 296 | state_settings["signed_pre_key_rotation_period"] = -random.randrange(1, 2**64) 297 | create_state_expect(state_settings, ValueError, "signed_pre_key_rotation_period") 298 | state_settings["signed_pre_key_rotation_period"] = 1 299 | 300 | # Pass an invalid combination of pre_key_refill_threshold and pre_key_refill_target 301 | # pre_key_refill_threshold too small 302 | state_settings["pre_key_refill_threshold"] = 0 303 | create_state_expect(state_settings, ValueError, "pre_key_refill_threshold") 304 | state_settings["pre_key_refill_threshold"] = 25 305 | 306 | # pre_key_refill_target too small 307 | state_settings["pre_key_refill_target"] = 0 308 | create_state_expect(state_settings, ValueError, "pre_key_refill_target") 309 | state_settings["pre_key_refill_target"] = 100 310 | 311 | # pre_key_refill_threshold above pre_key_refill_target 312 | state_settings["pre_key_refill_threshold"] = 100 313 | state_settings["pre_key_refill_target"] = 25 314 | create_state_expect(state_settings, ValueError, [ 315 | "pre_key_refill_threshold", 316 | "pre_key_refill_target" 317 | ]) 318 | state_settings["pre_key_refill_threshold"] = 25 319 | state_settings["pre_key_refill_target"] = 100 320 | 321 | # pre_key_refill_threshold equals pre_key_refill_target (this should succeed) 322 | state_settings["pre_key_refill_threshold"] = 25 323 | state_settings["pre_key_refill_target"] = 25 324 | create_state(state_settings) 325 | state_settings["pre_key_refill_threshold"] = 25 326 | state_settings["pre_key_refill_target"] = 100 327 | 328 | 329 | async def test_pre_key_refill() -> None: 330 | """ 331 | Test pre key refill. 332 | """ 333 | 334 | for state_settings in generate_settings( 335 | "test_pre_key_refill".encode("ASCII"), 336 | pre_key_refill_threshold=5, 337 | pre_key_refill_target=10 338 | ): 339 | state_a = create_state(state_settings) 340 | state_b = create_state(state_settings) 341 | 342 | # Verify that the bundle contains 100 pre keys initially: 343 | prev = len(get_bundle(state_b).pre_keys) 344 | assert prev == state_settings["pre_key_refill_target"] 345 | 346 | # Perform a lot of key agreements and verify that the refill works as expected: 347 | for _ in range(100): 348 | header = (await state_a.get_shared_secret_active(get_bundle(state_b)))[2] 349 | await state_b.get_shared_secret_passive(header) 350 | 351 | num_pre_keys = len(get_bundle(state_b).pre_keys) 352 | 353 | if prev == state_settings["pre_key_refill_threshold"]: 354 | assert num_pre_keys == state_settings["pre_key_refill_target"] 355 | else: 356 | assert num_pre_keys == prev - 1 357 | 358 | prev = num_pre_keys 359 | 360 | 361 | async def test_signed_pre_key_signature_verification() -> None: 362 | """ 363 | Test signature verification of the signed pre key. 364 | """ 365 | 366 | for state_settings in generate_settings("test_signed_pre_key_signature_verification".encode("ASCII")): 367 | identity_key_format: x3dh.IdentityKeyFormat = state_settings["identity_key_format"] 368 | 369 | for _ in range(8): 370 | state_a = create_state(state_settings) 371 | state_b = create_state(state_settings) 372 | 373 | bundle = get_bundle(state_b) 374 | 375 | # First, make sure that the active half of the key agreement usually works: 376 | await state_a.get_shared_secret_active(bundle) 377 | 378 | # Now, flip a random bit in 379 | # 1. the signature 380 | # 2. the identity key 381 | # 3. the signed pre key 382 | # and make sure that the active half of the key agreement reject the signature. 383 | 384 | # 1.: the signature 385 | signed_pre_key_sig = flip_random_bit(bundle.signed_pre_key_sig) 386 | bundle_modified = x3dh.Bundle( 387 | identity_key=bundle.identity_key, 388 | signed_pre_key=bundle.signed_pre_key, 389 | signed_pre_key_sig=signed_pre_key_sig, 390 | pre_keys=bundle.pre_keys 391 | ) 392 | try: 393 | await state_a.get_shared_secret_active(bundle_modified) 394 | assert False 395 | except x3dh.KeyAgreementException as e: 396 | assert "signature" in str(e) 397 | 398 | # 2.: the identity key 399 | exclude_msb = identity_key_format is x3dh.IdentityKeyFormat.CURVE_25519 400 | identity_key = flip_random_bit(bundle.identity_key, exclude_msb=exclude_msb) 401 | bundle_modified = x3dh.Bundle( 402 | identity_key=identity_key, 403 | signed_pre_key=bundle.signed_pre_key, 404 | signed_pre_key_sig=bundle.signed_pre_key_sig, 405 | pre_keys=bundle.pre_keys 406 | ) 407 | try: 408 | await state_a.get_shared_secret_active(bundle_modified) 409 | assert False 410 | except x3dh.KeyAgreementException as e: 411 | assert "signature" in str(e) 412 | 413 | # 3.: the signed pre key 414 | signed_pre_key = flip_random_bit(bundle.signed_pre_key) 415 | bundle_modified = x3dh.Bundle( 416 | identity_key=bundle.identity_key, 417 | signed_pre_key=signed_pre_key, 418 | signed_pre_key_sig=bundle.signed_pre_key_sig, 419 | pre_keys=bundle.pre_keys 420 | ) 421 | try: 422 | await state_a.get_shared_secret_active(bundle_modified) 423 | assert False 424 | except x3dh.KeyAgreementException as e: 425 | assert "signature" in str(e) 426 | 427 | 428 | async def test_pre_key_availability() -> None: 429 | """ 430 | Test whether key agreements without pre keys work/are rejected as expected. 431 | """ 432 | 433 | for state_settings in generate_settings("test_pre_key_availability".encode("ASCII")): 434 | state_a = create_state(state_settings) 435 | state_b = create_state(state_settings) 436 | 437 | # First, test the active half of the key agreement 438 | for require_pre_key in [ True, False ]: 439 | for include_pre_key in [ True, False ]: 440 | bundle = get_bundle(state_b) 441 | 442 | # Make sure that the bundle contains pre keys: 443 | assert len(bundle.pre_keys) > 0 444 | 445 | # If required for the test, remove all pre keys: 446 | if not include_pre_key: 447 | bundle = x3dh.Bundle( 448 | identity_key=bundle.identity_key, 449 | signed_pre_key=bundle.signed_pre_key, 450 | signed_pre_key_sig=bundle.signed_pre_key_sig, 451 | pre_keys=frozenset() 452 | ) 453 | 454 | should_fail = require_pre_key and not include_pre_key 455 | try: 456 | header = (await state_a.get_shared_secret_active( 457 | bundle, 458 | require_pre_key=require_pre_key 459 | ))[2] 460 | assert not should_fail 461 | assert (header.pre_key is not None) == include_pre_key 462 | except x3dh.KeyAgreementException as e: 463 | assert should_fail 464 | assert "does not contain" in str(e) 465 | assert "pre key" in str(e) 466 | 467 | # Second, test the passive half of the key agreement 468 | for require_pre_key in [ True, False ]: 469 | for include_pre_key in [ True, False ]: 470 | bundle = get_bundle(state_b) 471 | 472 | # Make sure that the bundle contains pre keys: 473 | assert len(bundle.pre_keys) > 0 474 | 475 | # If required for the test, remove all pre keys: 476 | if not include_pre_key: 477 | bundle = x3dh.Bundle( 478 | identity_key=bundle.identity_key, 479 | signed_pre_key=bundle.signed_pre_key, 480 | signed_pre_key_sig=bundle.signed_pre_key_sig, 481 | pre_keys=frozenset() 482 | ) 483 | 484 | # Perform the active half of the key agreement, using a pre key only if required for 485 | # the test. 486 | shared_secret_active, _, header = await state_a.get_shared_secret_active( 487 | bundle, 488 | require_pre_key=False 489 | ) 490 | 491 | should_fail = require_pre_key and not include_pre_key 492 | try: 493 | shared_secret_passive, _, _ = await state_b.get_shared_secret_passive( 494 | header, 495 | require_pre_key=require_pre_key 496 | ) 497 | assert not should_fail 498 | assert shared_secret_passive == shared_secret_active 499 | except x3dh.KeyAgreementException as e: 500 | assert should_fail 501 | assert "does not use" in str(e) 502 | assert "pre key" in str(e) 503 | 504 | 505 | THREE_DAYS = 3 * 24 * 60 * 60 506 | EIGHT_DAYS = 8 * 24 * 60 * 60 507 | 508 | 509 | async def test_signed_pre_key_rotation() -> None: 510 | """ 511 | Test signed pre key rotation logic. 512 | """ 513 | 514 | for state_settings in generate_settings("test_signed_pre_key_rotation".encode("ASCII")): 515 | state_b = create_state(state_settings) 516 | bundle_b = get_bundle(state_b) 517 | 518 | current_time = time.time() 519 | time_mock = mock.MagicMock() 520 | 521 | # Mock time.time, so that the test can skip days in an instant 522 | with mock.patch("time.time", time_mock): 523 | # ExampleState.create should call time.time only once, when generating the signed pre key. Make 524 | # the mock return the actual current time for that call. 525 | time_mock.return_value = current_time 526 | state_a = create_state(state_settings) 527 | assert time_mock.call_count == 1 528 | time_mock.reset_mock() 529 | 530 | # Prepare a key agreement header, the time is irrelevant here. Don't use a pre key so 531 | # that the header can be used multiple times. 532 | bundle_a = get_bundle(state_a) 533 | bundle_a = x3dh.Bundle( 534 | identity_key=bundle_a.identity_key, 535 | signed_pre_key=bundle_a.signed_pre_key, 536 | signed_pre_key_sig=bundle_a.signed_pre_key_sig, 537 | pre_keys=frozenset() 538 | ) 539 | 540 | time_mock.return_value = current_time + THREE_DAYS 541 | header_b = (await state_b.get_shared_secret_active(bundle_a, require_pre_key=False))[2] 542 | state_b.rotate_signed_pre_key() 543 | assert time_mock.call_count == 1 544 | time_mock.reset_mock() 545 | 546 | # There are three methods that check whether the signed pre key has to be rotated: 547 | # 1. get_shared_secret_active 548 | # 2. get_shared_secret_passive 549 | # 3. deserialize 550 | 551 | # 1. get_shared_secret_active 552 | 553 | # Make the mock return the actual current time plus three days. This should not trigger a 554 | # rotation. 555 | bundle_a_before = get_bundle(state_a) 556 | time_mock.return_value = current_time + THREE_DAYS 557 | await state_a.get_shared_secret_active(bundle_b) 558 | state_a.rotate_signed_pre_key() 559 | assert time_mock.call_count == 1 560 | time_mock.reset_mock() 561 | assert get_bundle(state_a) == bundle_a_before 562 | 563 | # Make the mock return the actual current time plus eight days. This should trigger a rotation. 564 | # A rotation reads the time twice. 565 | bundle_a_before = get_bundle(state_a) 566 | time_mock.return_value = current_time + EIGHT_DAYS 567 | await state_a.get_shared_secret_active(bundle_b) 568 | state_a.rotate_signed_pre_key() 569 | assert time_mock.call_count == 2 570 | time_mock.reset_mock() 571 | assert get_bundle(state_a).identity_key == bundle_a_before.identity_key 572 | assert get_bundle(state_a).signed_pre_key != bundle_a_before.signed_pre_key 573 | assert get_bundle(state_a).signed_pre_key_sig != bundle_a_before.signed_pre_key_sig 574 | assert get_bundle(state_a).pre_keys == bundle_a_before.pre_keys 575 | 576 | # Update the "current_time" to the creation time of the last signed pre key: 577 | current_time += EIGHT_DAYS 578 | 579 | # 2. get_shared_secret_passive 580 | 581 | # Make the mock return the actual current time plus three days. This should not trigger a 582 | # rotation. 583 | bundle_a_before = get_bundle(state_a) 584 | time_mock.return_value = current_time + THREE_DAYS 585 | await state_a.get_shared_secret_passive(header_b, require_pre_key=False) 586 | state_a.rotate_signed_pre_key() 587 | assert time_mock.call_count == 1 588 | time_mock.reset_mock() 589 | assert get_bundle(state_a) == bundle_a_before 590 | 591 | # Make the mock return the actual current time plus eight days. This should trigger a rotation. 592 | # A rotation reads the time twice. 593 | bundle_a_before = get_bundle(state_a) 594 | time_mock.return_value = current_time + EIGHT_DAYS 595 | await state_a.get_shared_secret_passive(header_b, require_pre_key=False) 596 | state_a.rotate_signed_pre_key() 597 | assert time_mock.call_count == 2 598 | time_mock.reset_mock() 599 | assert get_bundle(state_a).identity_key == bundle_a_before.identity_key 600 | assert get_bundle(state_a).signed_pre_key != bundle_a_before.signed_pre_key 601 | assert get_bundle(state_a).signed_pre_key_sig != bundle_a_before.signed_pre_key_sig 602 | assert get_bundle(state_a).pre_keys == bundle_a_before.pre_keys 603 | 604 | # Update the "current_time" to the creation time of the last signed pre key: 605 | current_time += EIGHT_DAYS 606 | 607 | # 3. deserialize 608 | 609 | # Make the mock return the actual current time plus three days. This should not trigger a 610 | # rotation. 611 | bundle_a_before = get_bundle(state_a) 612 | time_mock.return_value = current_time + THREE_DAYS 613 | state_a = ExampleState.from_model(state_a.model, **state_settings) 614 | assert time_mock.call_count == 1 615 | time_mock.reset_mock() 616 | assert get_bundle(state_a) == bundle_a_before 617 | 618 | # Make the mock return the actual current time plus eight days. This should trigger a rotation. 619 | # A rotation reads the time twice. 620 | bundle_a_before = get_bundle(state_a) 621 | time_mock.return_value = current_time + EIGHT_DAYS 622 | state_a = ExampleState.from_model(state_a.model, **state_settings) 623 | assert time_mock.call_count == 2 624 | time_mock.reset_mock() 625 | assert get_bundle(state_a).identity_key == bundle_a_before.identity_key 626 | assert get_bundle(state_a).signed_pre_key != bundle_a_before.signed_pre_key 627 | assert get_bundle(state_a).signed_pre_key_sig != bundle_a_before.signed_pre_key_sig 628 | assert get_bundle(state_a).pre_keys == bundle_a_before.pre_keys 629 | 630 | # Update the "current_time" to the creation time of the last signed pre key: 631 | current_time += EIGHT_DAYS 632 | 633 | 634 | async def test_old_signed_pre_key() -> None: 635 | """ 636 | Test that the old signed pre key remains available for key agreements for one further rotation period. 637 | """ 638 | 639 | for state_settings in generate_settings( 640 | "test_old_signed_pre_key".encode("ASCII"), 641 | signed_pre_key_rotation_period=2 642 | ): 643 | print(state_settings) 644 | state_a = create_state(state_settings) 645 | state_b = create_state(state_settings) 646 | 647 | # Prepare a key agreement header using the current signed pre key of state a. Don't use a pre 648 | # key so that the header can be used multiple times. 649 | bundle_a = get_bundle(state_a) 650 | bundle_a_no_pre_keys = x3dh.Bundle( 651 | identity_key=bundle_a.identity_key, 652 | signed_pre_key=bundle_a.signed_pre_key, 653 | signed_pre_key_sig=bundle_a.signed_pre_key_sig, 654 | pre_keys=frozenset() 655 | ) 656 | shared_secret_active, associated_data_active, header = await state_b.get_shared_secret_active( 657 | bundle_a_no_pre_keys, 658 | require_pre_key=False 659 | ) 660 | 661 | # Make sure that this key agreement works as intended: 662 | shared_secret_passive, associated_data_passive, _ = await state_a.get_shared_secret_passive( 663 | header, 664 | require_pre_key=False 665 | ) 666 | assert shared_secret_active == shared_secret_passive 667 | assert associated_data_active == associated_data_passive 668 | 669 | # Rotate the signed pre key once. The rotation period is specified as two days, still skipping eight 670 | # days should only trigger a single rotation. 671 | current_time = time.time() 672 | time_mock = mock.MagicMock() 673 | 674 | # Mock time.time, so that the test can skip days in an instant 675 | with mock.patch("time.time", time_mock): 676 | time_mock.return_value = current_time + EIGHT_DAYS 677 | state_a = ExampleState.from_model(state_a.model, **state_settings) 678 | assert time_mock.call_count == 2 679 | time_mock.reset_mock() 680 | 681 | # Make sure that the signed pre key was rotated: 682 | assert get_bundle(state_a).identity_key == bundle_a.identity_key 683 | assert get_bundle(state_a).signed_pre_key != bundle_a.signed_pre_key 684 | assert get_bundle(state_a).signed_pre_key_sig != bundle_a.signed_pre_key_sig 685 | assert get_bundle(state_a).pre_keys == bundle_a.pre_keys 686 | 687 | bundle_a_rotated = get_bundle(state_a) 688 | 689 | # The old signed pre key should still be stored in state_a, thus the old key agreement header should 690 | # still work: 691 | shared_secret_passive, associated_data_passive, _ = await state_a.get_shared_secret_passive( 692 | header, 693 | require_pre_key=False 694 | ) 695 | assert shared_secret_active == shared_secret_passive 696 | assert associated_data_active == associated_data_passive 697 | 698 | # Rotate the signed pre key again: 699 | with mock.patch("time.time", time_mock): 700 | time_mock.return_value = current_time + EIGHT_DAYS + THREE_DAYS 701 | state_a = ExampleState.from_model(state_a.model, **state_settings) 702 | assert time_mock.call_count == 2 703 | time_mock.reset_mock() 704 | 705 | # Make sure that the signed pre key was rotated again: 706 | assert get_bundle(state_a).identity_key == bundle_a.identity_key 707 | assert get_bundle(state_a).signed_pre_key != bundle_a.signed_pre_key 708 | assert get_bundle(state_a).signed_pre_key_sig != bundle_a.signed_pre_key_sig 709 | assert get_bundle(state_a).pre_keys == bundle_a.pre_keys 710 | assert get_bundle(state_a).identity_key == bundle_a_rotated.identity_key 711 | assert get_bundle(state_a).signed_pre_key != bundle_a_rotated.signed_pre_key 712 | assert get_bundle(state_a).signed_pre_key_sig != bundle_a_rotated.signed_pre_key_sig 713 | assert get_bundle(state_a).pre_keys == bundle_a_rotated.pre_keys 714 | 715 | # Now the signed pre key used in the header should not be available any more, the passive half of the 716 | # key agreement should fail: 717 | try: 718 | await state_a.get_shared_secret_passive(header, require_pre_key=False) 719 | assert False 720 | except x3dh.KeyAgreementException as e: 721 | assert "signed pre key" in str(e) 722 | assert "not available" in str(e) 723 | 724 | 725 | async def test_serialization() -> None: 726 | """ 727 | Test (de)serialization. 728 | """ 729 | 730 | for state_settings in generate_settings("test_serialization".encode("ASCII")): 731 | state_a = create_state(state_settings) 732 | state_b = create_state(state_settings) 733 | 734 | # Make sure that the key agreement works normally: 735 | shared_secret_active, associated_data_acitve, header = await state_a.get_shared_secret_active( 736 | get_bundle(state_b) 737 | ) 738 | shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) 739 | assert shared_secret_active == shared_secret_passive 740 | assert associated_data_acitve == associated_data_passive 741 | 742 | # Do the same thing but serialize and deserialize state b before performing the passive half of the 743 | # key agreement: 744 | bundle_b_before = get_bundle(state_b) 745 | 746 | shared_secret_active, associated_data_acitve, header = await state_a.get_shared_secret_active( 747 | get_bundle(state_b) 748 | ) 749 | state_b = ExampleState.from_model(state_b.model, **state_settings) 750 | shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) 751 | assert shared_secret_active == shared_secret_passive 752 | assert associated_data_acitve == associated_data_passive 753 | 754 | # Make sure that the bundle remained the same, except for one pre key being deleted: 755 | assert get_bundle(state_b).identity_key == bundle_b_before.identity_key 756 | assert get_bundle(state_b).signed_pre_key == bundle_b_before.signed_pre_key 757 | assert get_bundle(state_b).signed_pre_key_sig == bundle_b_before.signed_pre_key_sig 758 | assert len(get_bundle(state_b).pre_keys) == len(bundle_b_before.pre_keys) - 1 759 | assert all(pre_key in bundle_b_before.pre_keys for pre_key in get_bundle(state_b).pre_keys) 760 | 761 | # Accepting a key agreement using a pre key results in the pre key being deleted 762 | # from the state. Use (de)serialization to circumvent the deletion of the pre key. This time 763 | # also serialize the structure into JSON: 764 | shared_secret_active, associated_data_acitve, header = await state_a.get_shared_secret_active( 765 | get_bundle(state_b) 766 | ) 767 | state_b_serialized = json.dumps(state_b.json) 768 | 769 | # Accepting the header should work once... 770 | shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) 771 | assert shared_secret_active == shared_secret_passive 772 | assert associated_data_acitve == associated_data_passive 773 | 774 | # ...but fail the second time: 775 | try: 776 | await state_b.get_shared_secret_passive(header) 777 | assert False 778 | except x3dh.KeyAgreementException as e: 779 | assert "pre key" in str(e) 780 | assert "not available" in str(e) 781 | 782 | # After restoring the state, it should work again: 783 | state_b, needs_publish = ExampleState.from_json(json.loads(state_b_serialized), **state_settings) 784 | shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) 785 | assert not needs_publish 786 | assert shared_secret_active == shared_secret_passive 787 | assert associated_data_acitve == associated_data_passive 788 | 789 | 790 | THIS_FILE_PATH = os.path.dirname(os.path.abspath(__file__)) 791 | 792 | 793 | async def test_migrations() -> None: 794 | """ 795 | Test the migration from pre-stable. 796 | """ 797 | 798 | state_settings: Dict[str, Any] = { 799 | "identity_key_format": x3dh.IdentityKeyFormat.CURVE_25519, 800 | "hash_function": x3dh.HashFunction.SHA_256, 801 | "info": "test_migrations".encode("ASCII"), 802 | "signed_pre_key_rotation_period": 7, 803 | "pre_key_refill_threshold": 25, 804 | "pre_key_refill_target": 100 805 | } 806 | 807 | with open(os.path.join( 808 | THIS_FILE_PATH, 809 | "migration_data", 810 | "state-alice-pre-stable.json" 811 | ), "r", encoding="utf-8") as state_alice_pre_stable_json: 812 | state_a_serialized = json.load(state_alice_pre_stable_json) 813 | 814 | with open(os.path.join( 815 | THIS_FILE_PATH, 816 | "migration_data", 817 | "state-bob-pre-stable.json" 818 | ), "r", encoding="utf-8") as state_bob_pre_stable_json: 819 | state_b_serialized = json.load(state_bob_pre_stable_json) 820 | 821 | with open(os.path.join( 822 | THIS_FILE_PATH, 823 | "migration_data", 824 | "shared-secret-pre-stable.json" 825 | ), "r", encoding="utf-8") as shared_secret_pey_stable_json: 826 | shared_secret_active_serialized = json.load(shared_secret_pey_stable_json) 827 | 828 | # Convert the pre-stable shared secret structure into a x3dh.SharedSecretActive 829 | shared_secret_active = base64.b64decode(shared_secret_active_serialized["sk"].encode("ASCII")) 830 | associated_data_active = base64.b64decode(shared_secret_active_serialized["ad"].encode("ASCII")) 831 | header = x3dh.Header( 832 | identity_key=base64.b64decode(shared_secret_active_serialized["to_other"]["ik"].encode("ASCII")), 833 | ephemeral_key=base64.b64decode(shared_secret_active_serialized["to_other"]["ek"].encode("ASCII")), 834 | signed_pre_key=base64.b64decode(shared_secret_active_serialized["to_other"]["spk"].encode("ASCII")), 835 | pre_key=base64.b64decode(shared_secret_active_serialized["to_other"]["otpk"].encode("ASCII")) 836 | ) 837 | 838 | # Load state a. This should not trigger a publishing of the bundle, as the `changed` flag is not set. 839 | state_a, _needs_publish = ExampleState.from_json(state_a_serialized, **state_settings) 840 | 841 | try: 842 | get_bundle(state_a) 843 | assert False 844 | except AssertionError: 845 | pass 846 | 847 | # Load state b. This should trigger a publishing of the bundle, as the `changed` flag is set. 848 | state_b, _needs_publish = ExampleState.from_json(state_b_serialized, **state_settings) 849 | 850 | get_bundle(state_b) 851 | 852 | # Complete the passive half of the key agreement as created by the pre-stable version: 853 | shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) 854 | assert shared_secret_active == shared_secret_passive 855 | # Don't check the associated data, since formats have changed. 856 | 857 | # Try another key agreement using the migrated sessions: 858 | shared_secret_active, associated_data_active, header = await state_a.get_shared_secret_active( 859 | get_bundle(state_b) 860 | ) 861 | shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) 862 | assert shared_secret_active == shared_secret_passive 863 | assert associated_data_active == associated_data_passive 864 | -------------------------------------------------------------------------------- /x3dh/__init__.py: -------------------------------------------------------------------------------- 1 | from .version import __version__ as __version__ 2 | from .project import project as project 3 | 4 | from .base_state import ( 5 | KeyAgreementException as KeyAgreementException, 6 | BaseState as BaseState 7 | ) 8 | from .crypto_provider import HashFunction as HashFunction 9 | from .models import ( 10 | BaseStateModel as BaseStateModel, 11 | IdentityKeyPairModel as IdentityKeyPairModel, 12 | SignedPreKeyPairModel as SignedPreKeyPairModel 13 | ) 14 | from .state import State as State 15 | from .types import ( 16 | Bundle as Bundle, 17 | Header as Header, 18 | IdentityKeyFormat as IdentityKeyFormat, 19 | JSONObject as JSONObject 20 | ) 21 | -------------------------------------------------------------------------------- /x3dh/base_state.py: -------------------------------------------------------------------------------- 1 | # This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better 2 | from __future__ import annotations 3 | 4 | from abc import ABC, abstractmethod 5 | import json 6 | import time 7 | import secrets 8 | from typing import FrozenSet, Optional, Set, Tuple, Type, TypeVar, cast 9 | 10 | import xeddsa 11 | 12 | from .crypto_provider import HashFunction 13 | from .crypto_provider_cryptography import CryptoProviderImpl 14 | from .identity_key_pair import IdentityKeyPair, IdentityKeyPairSeed 15 | from .migrations import parse_base_state_model 16 | from .models import BaseStateModel 17 | from .pre_key_pair import PreKeyPair 18 | from .signed_pre_key_pair import SignedPreKeyPair 19 | from .types import Bundle, IdentityKeyFormat, Header, JSONObject 20 | 21 | 22 | __all__ = [ 23 | "KeyAgreementException", 24 | "BaseState" 25 | ] 26 | 27 | 28 | class KeyAgreementException(Exception): 29 | """ 30 | Exception raised by :meth:`BaseState.get_shared_secret_active` and 31 | :meth:`BaseState.get_shared_secret_passive` in case of an error related to the key agreement operation. 32 | """ 33 | 34 | 35 | BaseStateTypeT = TypeVar("BaseStateTypeT", bound="BaseState") 36 | 37 | 38 | class BaseState(ABC): 39 | """ 40 | This class is the core of this X3DH implementation. It offers methods to manually manage the X3DH state 41 | and perform key agreements with other parties. 42 | 43 | Warning: 44 | This class requires manual state management, including e.g. signed pre key rotation, pre key 45 | hiding/deletion and refills. The subclass :class:`~x3dh.state.State` automates those 46 | management/maintenance tasks and should be preferred if external/manual management is not explicitly 47 | wanted. 48 | """ 49 | 50 | def __init__(self) -> None: 51 | # Just the type definitions here 52 | self.__identity_key_format: IdentityKeyFormat 53 | self.__hash_function: HashFunction 54 | self.__info: bytes 55 | self.__identity_key: IdentityKeyPair 56 | self.__signed_pre_key: SignedPreKeyPair 57 | self.__old_signed_pre_key: Optional[SignedPreKeyPair] 58 | self.__pre_keys: Set[PreKeyPair] 59 | self.__hidden_pre_keys: Set[PreKeyPair] 60 | 61 | @classmethod 62 | def create( 63 | cls: Type[BaseStateTypeT], 64 | identity_key_format: IdentityKeyFormat, 65 | hash_function: HashFunction, 66 | info: bytes, 67 | identity_key_pair: Optional[IdentityKeyPair] = None 68 | ) -> BaseStateTypeT: 69 | """ 70 | Args: 71 | identity_key_format: The format in which the identity public key is included in bundles/headers. 72 | hash_function: A 256 or 512-bit hash function. 73 | info: A (byte) string identifying the application. 74 | identity_key_pair: If set, use the given identity key pair instead of generating a new one. 75 | 76 | Returns: 77 | A configured instance of :class:`~x3dh.base_state.BaseState`. Note that an identity key pair and a 78 | signed pre key are generated, but no pre keys. Use :meth:`generate_pre_keys` to generate some. 79 | """ 80 | 81 | self = cls() 82 | self.__identity_key_format = identity_key_format 83 | self.__hash_function = hash_function 84 | self.__info = info 85 | self.__identity_key = identity_key_pair or IdentityKeyPairSeed(secrets.token_bytes(32)) 86 | self.__signed_pre_key = self.__generate_spk() 87 | self.__old_signed_pre_key = None 88 | self.__pre_keys = set() 89 | self.__hidden_pre_keys = set() 90 | 91 | return self 92 | 93 | #################### 94 | # abstract methods # 95 | #################### 96 | 97 | @staticmethod 98 | @abstractmethod 99 | def _encode_public_key(key_format: IdentityKeyFormat, pub: bytes) -> bytes: 100 | """ 101 | Args: 102 | key_format: The format in which this public key is serialized. 103 | pub: The public key. 104 | 105 | Returns: 106 | An encoding of the public key, possibly including information about the curve and type of key, 107 | though this is application defined. Note that two different public keys must never result in the 108 | same byte sequence, uniqueness of the public keys must be preserved. 109 | """ 110 | 111 | raise NotImplementedError("Create a subclass of BaseState and implement `_encode_public_key`.") 112 | 113 | ################# 114 | # serialization # 115 | ################# 116 | 117 | @property 118 | def model(self) -> BaseStateModel: 119 | """ 120 | Returns: 121 | The internal state of this :class:`BaseState` as a pydantic model. Note that pre keys hidden using 122 | :meth:`hide_pre_key` are not considered part of the state. 123 | """ 124 | 125 | return BaseStateModel( 126 | identity_key=self.__identity_key.model, 127 | signed_pre_key=self.__signed_pre_key.model, 128 | old_signed_pre_key=None if self.__old_signed_pre_key is None else self.__old_signed_pre_key.model, 129 | pre_keys=frozenset(pre_key.priv for pre_key in self.__pre_keys) 130 | ) 131 | 132 | @property 133 | def json(self) -> JSONObject: 134 | """ 135 | Returns: 136 | The internal state of this :class:`BaseState` as a JSON-serializable Python object. Note that pre 137 | keys hidden using :meth:`hide_pre_key` are not considered part of the state. 138 | """ 139 | 140 | return cast(JSONObject, json.loads(self.model.model_dump_json())) 141 | 142 | @classmethod 143 | def from_model( 144 | cls: Type[BaseStateTypeT], 145 | model: BaseStateModel, 146 | identity_key_format: IdentityKeyFormat, 147 | hash_function: HashFunction, 148 | info: bytes 149 | ) -> BaseStateTypeT: 150 | """ 151 | Args: 152 | model: The pydantic model holding the internal state of a :class:`BaseState`, as produced by 153 | :attr:`model`. 154 | identity_key_format: The format in which the identity public key is included in bundles/headers. 155 | hash_function: A 256 or 512-bit hash function. 156 | info: A (byte) string identifying the application. 157 | 158 | Returns: 159 | A configured instance of :class:`BaseState`, with internal state restored from the model. 160 | 161 | Warning: 162 | Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use 163 | :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the 164 | documentation for details. 165 | """ 166 | 167 | self = cls() 168 | self.__identity_key_format = identity_key_format 169 | self.__hash_function = hash_function 170 | self.__info = info 171 | self.__identity_key = IdentityKeyPair.from_model(model.identity_key) 172 | self.__signed_pre_key = SignedPreKeyPair.from_model(model.signed_pre_key) 173 | self.__old_signed_pre_key = ( 174 | None 175 | if model.old_signed_pre_key is None 176 | else SignedPreKeyPair.from_model(model.old_signed_pre_key) 177 | ) 178 | self.__pre_keys = { PreKeyPair(pre_key) for pre_key in model.pre_keys } 179 | self.__hidden_pre_keys = set() 180 | 181 | return self 182 | 183 | @classmethod 184 | def from_json( 185 | cls: Type[BaseStateTypeT], 186 | serialized: JSONObject, 187 | identity_key_format: IdentityKeyFormat, 188 | hash_function: HashFunction, 189 | info: bytes 190 | ) -> Tuple[BaseStateTypeT, bool]: 191 | """ 192 | Args: 193 | serialized: A JSON-serializable Python object holding the internal state of a :class:`BaseState`, 194 | as produced by :attr:`json`. 195 | identity_key_format: The format in which the identity public key is included in bundles/headers. 196 | hash_function: A 256 or 512-bit hash function. 197 | info: A (byte) string identifying the application. 198 | 199 | Returns: 200 | A configured instance of :class:`BaseState`, with internal state restored from the serialized 201 | data, and a flag that indicates whether the bundle needs to be published. The latter was part of 202 | the pre-stable serialization format. 203 | """ 204 | 205 | model, bundle_needs_publish = parse_base_state_model(serialized) 206 | 207 | self = cls.from_model( 208 | model, 209 | identity_key_format, 210 | hash_function, 211 | info 212 | ) 213 | 214 | return self, bundle_needs_publish 215 | 216 | ################################# 217 | # key generation and management # 218 | ################################# 219 | 220 | def __generate_spk(self) -> SignedPreKeyPair: 221 | """ 222 | Returns: 223 | A newly generated signed pre key. 224 | """ 225 | 226 | # Get the own identity key in the format required for signing, forcing the sign bit if necessary to 227 | # comply with XEdDSA 228 | identity_key = self.__identity_key.as_priv().priv 229 | if self.__identity_key_format is IdentityKeyFormat.CURVE_25519: 230 | identity_key = xeddsa.priv_force_sign(identity_key, False) 231 | 232 | # Generate the private key of the new signed pre key 233 | priv = secrets.token_bytes(32) 234 | 235 | # Sign the encoded public key of the new signed pre key 236 | sig = xeddsa.ed25519_priv_sign( 237 | identity_key, 238 | self._encode_public_key(IdentityKeyFormat.CURVE_25519, xeddsa.priv_to_curve25519_pub(priv)) 239 | ) 240 | 241 | # Add the current timestamp 242 | return SignedPreKeyPair(priv=priv, sig=sig, timestamp=int(time.time())) 243 | 244 | @property 245 | def old_signed_pre_key(self) -> Optional[bytes]: 246 | """ 247 | Returns: 248 | The old signed pre key, if there is one. 249 | """ 250 | 251 | return None if self.__old_signed_pre_key is None else self.__old_signed_pre_key.pub 252 | 253 | def signed_pre_key_age(self) -> int: 254 | """ 255 | Returns: 256 | The age of the signed pre key, i.e. the time elapsed since it was last rotated, in seconds. 257 | """ 258 | 259 | return int(time.time()) - self.__signed_pre_key.timestamp 260 | 261 | def rotate_signed_pre_key(self) -> None: 262 | """ 263 | Rotate the signed pre key. Keep the old signed pre key around for one additional rotation period, i.e. 264 | until this method is called again. 265 | """ 266 | 267 | self.__old_signed_pre_key = self.__signed_pre_key 268 | self.__signed_pre_key = self.__generate_spk() 269 | 270 | @property 271 | def hidden_pre_keys(self) -> FrozenSet[bytes]: 272 | """ 273 | Returns: 274 | The currently hidden pre keys. 275 | """ 276 | 277 | return frozenset(pre_key.pub for pre_key in self.__hidden_pre_keys) 278 | 279 | def hide_pre_key(self, pre_key_pub: bytes) -> bool: 280 | """ 281 | Hide a pre key from the bundle returned by :attr:`bundle` and pre key count returned by 282 | :meth:`get_num_visible_pre_keys`, but keep the pre key for cryptographic operations. Hidden pre keys 283 | are not included in the serialized state as returned by :attr:`model` and :attr:`json`. 284 | 285 | Args: 286 | pre_key_pub: The pre key to hide. 287 | 288 | Returns: 289 | Whether the pre key was visible before and is hidden now. 290 | """ 291 | 292 | hidden_pre_keys = frozenset(filter(lambda pre_key: pre_key.pub == pre_key_pub, self.__pre_keys)) 293 | 294 | self.__pre_keys -= hidden_pre_keys 295 | self.__hidden_pre_keys |= hidden_pre_keys 296 | 297 | return len(hidden_pre_keys) > 0 298 | 299 | def delete_pre_key(self, pre_key_pub: bytes) -> bool: 300 | """ 301 | Delete a pre key. 302 | 303 | Args: 304 | pre_key_pub: The pre key to delete. Can be visible or hidden. 305 | 306 | Returns: 307 | Whether the pre key existed before and is deleted now. 308 | """ 309 | 310 | deleted_pre_keys = frozenset(filter( 311 | lambda pre_key: pre_key.pub == pre_key_pub, 312 | self.__pre_keys | self.__hidden_pre_keys 313 | )) 314 | 315 | self.__pre_keys -= deleted_pre_keys 316 | self.__hidden_pre_keys -= deleted_pre_keys 317 | 318 | return len(deleted_pre_keys) > 0 319 | 320 | def delete_hidden_pre_keys(self) -> None: 321 | """ 322 | Delete all pre keys that were previously hidden using :meth:`hide_pre_key`. 323 | """ 324 | 325 | self.__hidden_pre_keys = set() 326 | 327 | def get_num_visible_pre_keys(self) -> int: 328 | """ 329 | Returns: 330 | The number of visible pre keys available. The number returned here matches the number of pre keys 331 | included in the bundle returned by :attr:`bundle`. 332 | """ 333 | 334 | return len(self.__pre_keys) 335 | 336 | def generate_pre_keys(self, num_pre_keys: int) -> None: 337 | """ 338 | Generate and store pre keys. 339 | 340 | Args: 341 | num_pre_keys: The number of pre keys to generate. 342 | """ 343 | 344 | for _ in range(num_pre_keys): 345 | self.__pre_keys.add(PreKeyPair(priv=secrets.token_bytes(32))) 346 | 347 | @property 348 | def bundle(self) -> Bundle: 349 | """ 350 | Returns: 351 | The bundle, i.e. the public information of this state. 352 | """ 353 | 354 | identity_key = self.__identity_key.as_priv().priv 355 | 356 | return Bundle( 357 | identity_key=( 358 | xeddsa.priv_to_curve25519_pub(identity_key) 359 | if self.__identity_key_format is IdentityKeyFormat.CURVE_25519 360 | else xeddsa.priv_to_ed25519_pub(identity_key) 361 | ), 362 | signed_pre_key=self.__signed_pre_key.pub, 363 | signed_pre_key_sig=self.__signed_pre_key.sig, 364 | pre_keys=frozenset(pre_key.pub for pre_key in self.__pre_keys) 365 | ) 366 | 367 | ################# 368 | # key agreement # 369 | ################# 370 | 371 | async def get_shared_secret_active( 372 | self, 373 | bundle: Bundle, 374 | associated_data_appendix: bytes = b"", 375 | require_pre_key: bool = True 376 | ) -> Tuple[bytes, bytes, Header]: 377 | """ 378 | Perform an X3DH key agreement, actively. 379 | 380 | Args: 381 | bundle: The bundle of the passive party. 382 | associated_data_appendix: Additional information to append to the associated data, like usernames, 383 | certificates or other identifying information. 384 | require_pre_key: Use this flag to abort the key agreement if the bundle does not contain a pre 385 | key. 386 | 387 | Returns: 388 | The shared secret and associated data shared between both parties, and the header required by the 389 | other party to complete the passive part of the key agreement. 390 | 391 | Raises: 392 | KeyAgreementException: If an error occurs during the key agreement. The exception message will 393 | contain (human-readable) details. 394 | """ 395 | 396 | # Check whether a pre key is required but not included 397 | if len(bundle.pre_keys) == 0 and require_pre_key: 398 | raise KeyAgreementException("This bundle does not contain a pre key.") 399 | 400 | # Get the identity key of the other party in the format required for signature verification 401 | other_identity_key = bundle.identity_key 402 | if self.__identity_key_format is IdentityKeyFormat.CURVE_25519: 403 | other_identity_key = xeddsa.curve25519_pub_to_ed25519_pub(other_identity_key, False) 404 | 405 | # Verify the signature on the signed pre key of the other party 406 | if not xeddsa.ed25519_verify( 407 | bundle.signed_pre_key_sig, 408 | other_identity_key, 409 | self._encode_public_key(IdentityKeyFormat.CURVE_25519, bundle.signed_pre_key) 410 | ): 411 | raise KeyAgreementException("The signature of the signed pre key could not be verified.") 412 | 413 | # All pre-checks successful. 414 | 415 | # Choose a pre key if available 416 | pre_key = None if len(bundle.pre_keys) == 0 else secrets.choice(list(bundle.pre_keys)) 417 | 418 | # Generate the ephemeral key required for the key agreement 419 | ephemeral_key = secrets.token_bytes(32) 420 | 421 | # Get the own identity key in the format required for X25519 422 | own_identity_key = self.__identity_key.as_priv().priv 423 | 424 | # Get the identity key of the other party in the format required for X25519 425 | other_identity_key = bundle.identity_key 426 | if self.__identity_key_format is IdentityKeyFormat.ED_25519: 427 | other_identity_key = xeddsa.ed25519_pub_to_curve25519_pub(other_identity_key) 428 | 429 | # Calculate the three to four Diffie-Hellman shared secrets that become the input of HKDF in the next 430 | # step 431 | dh1 = xeddsa.x25519(own_identity_key, bundle.signed_pre_key) 432 | dh2 = xeddsa.x25519(ephemeral_key, other_identity_key) 433 | dh3 = xeddsa.x25519(ephemeral_key, bundle.signed_pre_key) 434 | dh4 = b"" if pre_key is None else xeddsa.x25519(ephemeral_key, pre_key) 435 | 436 | # Prepare salt and padding 437 | salt = b"\x00" * self.__hash_function.hash_size 438 | padding = b"\xFF" * 32 439 | 440 | # Use HKDF to derive the final shared secret 441 | shared_secret = await CryptoProviderImpl.hkdf_derive( 442 | self.__hash_function, 443 | 32, 444 | salt, 445 | self.__info, 446 | padding + dh1 + dh2 + dh3 + dh4 447 | ) 448 | 449 | # Build the associated data for further use by other protocols 450 | associated_data = ( 451 | self._encode_public_key(self.__identity_key_format, self.bundle.identity_key) 452 | + self._encode_public_key(self.__identity_key_format, bundle.identity_key) 453 | + associated_data_appendix 454 | ) 455 | 456 | # Build the header required by the other party to complete the passive part of the key agreement 457 | header = Header( 458 | identity_key=self.bundle.identity_key, 459 | ephemeral_key=xeddsa.priv_to_curve25519_pub(ephemeral_key), 460 | pre_key=pre_key, 461 | signed_pre_key=bundle.signed_pre_key 462 | ) 463 | 464 | return shared_secret, associated_data, header 465 | 466 | async def get_shared_secret_passive( 467 | self, 468 | header: Header, 469 | associated_data_appendix: bytes = b"", 470 | require_pre_key: bool = True 471 | ) -> Tuple[bytes, bytes, SignedPreKeyPair]: 472 | """ 473 | Perform an X3DH key agreement, passively. 474 | 475 | Args: 476 | header: The header received from the active party. 477 | associated_data_appendix: Additional information to append to the associated data, like usernames, 478 | certificates or other identifying information. 479 | require_pre_key: Use this flag to abort the key agreement if the active party did not use a pre 480 | key. 481 | 482 | Returns: 483 | The shared secret and the associated data shared between both parties, and the signed pre key pair 484 | that was used during the key exchange, for use by follow-up protocols. 485 | 486 | Raises: 487 | KeyAgreementException: If an error occurs during the key agreement. The exception message will 488 | contain (human-readable) details. 489 | """ 490 | 491 | # Check whether the signed pre key used by this initiation is still available 492 | signed_pre_key: Optional[SignedPreKeyPair] = None 493 | 494 | if header.signed_pre_key == self.__signed_pre_key.pub: 495 | # The current signed pre key was used 496 | signed_pre_key = self.__signed_pre_key 497 | 498 | if self.__old_signed_pre_key is not None and header.signed_pre_key == self.__old_signed_pre_key.pub: 499 | # The old signed pre key was used 500 | signed_pre_key = self.__old_signed_pre_key 501 | 502 | if signed_pre_key is None: 503 | raise KeyAgreementException( 504 | "This key agreement attempt uses a signed pre key that is not available any more." 505 | ) 506 | 507 | # Check whether a pre key is required but not used 508 | if header.pre_key is None and require_pre_key: 509 | raise KeyAgreementException("This key agreement attempt does not use a pre key.") 510 | 511 | # If a pre key was used, check whether it is still available 512 | pre_key: Optional[bytes] = None 513 | if header.pre_key is not None: 514 | pre_key = next(( 515 | pre_key.priv 516 | for pre_key 517 | in self.__pre_keys | self.__hidden_pre_keys 518 | if pre_key.pub == header.pre_key 519 | ), None) 520 | 521 | if pre_key is None: 522 | raise KeyAgreementException( 523 | "This key agreement attempt uses a pre key that is not available any more." 524 | ) 525 | 526 | # Get the own identity key in the format required for X25519 527 | own_identity_key = self.__identity_key.as_priv().priv 528 | 529 | # Get the identity key of the other party in the format required for X25519 530 | other_identity_key = header.identity_key 531 | if self.__identity_key_format is IdentityKeyFormat.ED_25519: 532 | other_identity_key = xeddsa.ed25519_pub_to_curve25519_pub(other_identity_key) 533 | 534 | # Calculate the three to four Diffie-Hellman shared secrets that become the input of HKDF in the next 535 | # step 536 | dh1 = xeddsa.x25519(signed_pre_key.priv, other_identity_key) 537 | dh2 = xeddsa.x25519(own_identity_key, header.ephemeral_key) 538 | dh3 = xeddsa.x25519(signed_pre_key.priv, header.ephemeral_key) 539 | dh4 = b"" if pre_key is None else xeddsa.x25519(pre_key, header.ephemeral_key) 540 | 541 | # Prepare salt and padding 542 | salt = b"\x00" * self.__hash_function.hash_size 543 | padding = b"\xFF" * 32 544 | 545 | # Use HKDF to derive the final shared secret 546 | shared_secret = await CryptoProviderImpl.hkdf_derive( 547 | self.__hash_function, 548 | 32, 549 | salt, 550 | self.__info, 551 | padding + dh1 + dh2 + dh3 + dh4 552 | ) 553 | 554 | # Build the associated data for further use by other protocols 555 | associated_data = ( 556 | self._encode_public_key(self.__identity_key_format, header.identity_key) 557 | + self._encode_public_key(self.__identity_key_format, self.bundle.identity_key) 558 | + associated_data_appendix 559 | ) 560 | 561 | return shared_secret, associated_data, signed_pre_key 562 | -------------------------------------------------------------------------------- /x3dh/crypto_provider.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | import enum 3 | from typing_extensions import assert_never 4 | 5 | 6 | __all__ = [ 7 | "CryptoProvider", 8 | "HashFunction" 9 | ] 10 | 11 | 12 | @enum.unique 13 | class HashFunction(enum.Enum): 14 | """ 15 | Enumeration of the hash functions supported for the key derivation step of X3DH. 16 | """ 17 | 18 | SHA_256: str = "SHA_256" 19 | SHA_512: str = "SHA_512" 20 | 21 | @property 22 | def hash_size(self) -> int: 23 | """ 24 | Returns: 25 | The byte size of the hashes produced by this hash function. 26 | """ 27 | 28 | if self is HashFunction.SHA_256: 29 | return 32 30 | if self is HashFunction.SHA_512: 31 | return 64 32 | 33 | return assert_never(self) 34 | 35 | 36 | class CryptoProvider(ABC): 37 | """ 38 | Abstraction of the cryptographic operations needed by this package to allow for different backend 39 | implementations. 40 | """ 41 | 42 | @staticmethod 43 | @abstractmethod 44 | async def hkdf_derive( 45 | hash_function: HashFunction, 46 | length: int, 47 | salt: bytes, 48 | info: bytes, 49 | key_material: bytes 50 | ) -> bytes: 51 | """ 52 | Args: 53 | hash_function: The hash function to parameterize the HKDF with. 54 | length: The number of bytes to derive. 55 | salt: The salt input for the HKDF. 56 | info: The info input for the HKDF. 57 | key_material: The input key material to derive from. 58 | 59 | Returns: 60 | The derived key material. 61 | """ 62 | -------------------------------------------------------------------------------- /x3dh/crypto_provider_cryptography.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import assert_never 2 | 3 | from cryptography.hazmat.backends import default_backend 4 | from cryptography.hazmat.primitives import hashes 5 | from cryptography.hazmat.primitives.kdf.hkdf import HKDF 6 | 7 | from .crypto_provider import CryptoProvider, HashFunction 8 | 9 | 10 | __all__ = [ 11 | "CryptoProviderImpl" 12 | ] 13 | 14 | 15 | def get_hash_algorithm(hash_function: HashFunction) -> hashes.HashAlgorithm: 16 | """ 17 | Args: 18 | hash_function: Identifier of a hash function. 19 | 20 | Returns: 21 | The implementation of the hash function as a cryptography 22 | :class:`~cryptography.hazmat.primitives.hashes.HashAlgorithm` object. 23 | """ 24 | 25 | if hash_function is HashFunction.SHA_256: 26 | return hashes.SHA256() 27 | if hash_function is HashFunction.SHA_512: 28 | return hashes.SHA512() 29 | 30 | return assert_never(hash_function) 31 | 32 | 33 | class CryptoProviderImpl(CryptoProvider): 34 | """ 35 | Cryptography provider based on the Python package `cryptography `_. 36 | """ 37 | 38 | @staticmethod 39 | async def hkdf_derive( 40 | hash_function: HashFunction, 41 | length: int, 42 | salt: bytes, 43 | info: bytes, 44 | key_material: bytes 45 | ) -> bytes: 46 | return HKDF( 47 | algorithm=get_hash_algorithm(hash_function), 48 | length=length, 49 | salt=salt, 50 | info=info, 51 | backend=default_backend() 52 | ).derive(key_material) 53 | -------------------------------------------------------------------------------- /x3dh/identity_key_pair.py: -------------------------------------------------------------------------------- 1 | # This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better 2 | from __future__ import annotations 3 | 4 | from abc import ABC, abstractmethod 5 | import json 6 | from typing import cast 7 | from typing_extensions import assert_never 8 | 9 | import xeddsa 10 | 11 | from .migrations import parse_identity_key_pair_model 12 | from .models import IdentityKeyPairModel 13 | from .types import JSONObject, SecretType 14 | 15 | 16 | __all__ = [ 17 | "IdentityKeyPair", 18 | "IdentityKeyPairPriv", 19 | "IdentityKeyPairSeed" 20 | ] 21 | 22 | 23 | class IdentityKeyPair(ABC): 24 | """ 25 | An identity key pair. 26 | 27 | There are following requirements for the identity key pair: 28 | 29 | * It must be able to create and verify Ed25519-compatible signatures. 30 | * It must be able to perform X25519-compatible Diffie-Hellman key agreements. 31 | 32 | There are at least two different kinds of key pairs that can fulfill these requirements: Ed25519 key pairs 33 | and Curve25519 key pairs. The birational equivalence of both curves can be used to "convert" one pair to 34 | the other. 35 | 36 | Both types of key pairs share the same private key, however instead of a private key, a seed can be used 37 | which the private key is derived from using SHA-512. This is standard practice for Ed25519, where the 38 | other 32 bytes of the SHA-512 seed hash are used as a nonce during signing. If a new key pair has to be 39 | generated, this implementation generates a seed. 40 | """ 41 | 42 | @property 43 | def model(self) -> IdentityKeyPairModel: 44 | """ 45 | Returns: 46 | The internal state of this :class:`IdentityKeyPair` as a pydantic model. 47 | """ 48 | 49 | return IdentityKeyPairModel(secret=self.secret, secret_type=self.secret_type) 50 | 51 | @property 52 | def json(self) -> JSONObject: 53 | """ 54 | Returns: 55 | The internal state of this :class:`IdentityKeyPair` as a JSON-serializable Python object. 56 | """ 57 | 58 | return cast(JSONObject, json.loads(self.model.json())) 59 | 60 | @staticmethod 61 | def from_model(model: IdentityKeyPairModel) -> "IdentityKeyPair": 62 | """ 63 | Args: 64 | model: The pydantic model holding the internal state of an :class:`IdentityKeyPair`, as produced 65 | by :attr:`model`. 66 | 67 | Returns: 68 | A configured instance of :class:`IdentityKeyPair`, with internal state restored from the model. 69 | 70 | Warning: 71 | Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use 72 | :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the 73 | documentation for details. 74 | """ 75 | 76 | if model.secret_type is SecretType.PRIV: 77 | return IdentityKeyPairPriv(model.secret) 78 | if model.secret_type is SecretType.SEED: 79 | return IdentityKeyPairSeed(model.secret) 80 | 81 | return assert_never(model.secret_type) 82 | 83 | @staticmethod 84 | def from_json(serialized: JSONObject) -> "IdentityKeyPair": 85 | """ 86 | Args: 87 | serialized: A JSON-serializable Python object holding the internal state of an 88 | :class:`IdentityKeyPair`, as produced by :attr:`json`. 89 | 90 | Returns: 91 | A configured instance of :class:`IdentityKeyPair`, with internal state restored from the 92 | serialized data. 93 | """ 94 | 95 | return IdentityKeyPair.from_model(parse_identity_key_pair_model(serialized)) 96 | 97 | @property 98 | @abstractmethod 99 | def secret_type(self) -> SecretType: 100 | """ 101 | Returns: 102 | The type of secret used by this identity key (i.e. a seed or private key). 103 | """ 104 | 105 | @property 106 | @abstractmethod 107 | def secret(self) -> bytes: 108 | """ 109 | Returns: 110 | The secret used by this identity key, i.e. the seed or private key. 111 | """ 112 | 113 | @abstractmethod 114 | def as_priv(self) -> "IdentityKeyPairPriv": 115 | """ 116 | Returns: 117 | An :class:`IdentityKeyPairPriv` derived from this instance, or the instance itself if it already 118 | is an :class:`IdentityKeyPairPriv`. 119 | """ 120 | 121 | 122 | class IdentityKeyPairPriv(IdentityKeyPair): 123 | """ 124 | An :class:`IdentityKeyPair` represented by a Curve25519/Ed25519 private key. 125 | """ 126 | 127 | def __init__(self, priv: bytes) -> None: 128 | """ 129 | Args: 130 | priv: The Curve25519/Ed25519 private key. 131 | """ 132 | 133 | if len(priv) != 32: 134 | raise ValueError("Expected the private key to be 32 bytes long.") 135 | 136 | self.__priv = priv 137 | 138 | @property 139 | def secret_type(self) -> SecretType: 140 | return SecretType.PRIV 141 | 142 | @property 143 | def secret(self) -> bytes: 144 | return self.priv 145 | 146 | def as_priv(self) -> "IdentityKeyPairPriv": 147 | return self 148 | 149 | @property 150 | def priv(self) -> bytes: 151 | """ 152 | Returns: 153 | The Curve25519/Ed25519 private key. 154 | """ 155 | 156 | return self.__priv 157 | 158 | 159 | class IdentityKeyPairSeed(IdentityKeyPair): 160 | """ 161 | An :class:`IdentityKeyPair` represented by a Curve25519/Ed25519 seed. 162 | """ 163 | 164 | def __init__(self, seed: bytes) -> None: 165 | """ 166 | Args: 167 | seed: The Curve25519/Ed25519 seed. 168 | """ 169 | 170 | if len(seed) != 32: 171 | raise ValueError("Expected the seed to be 32 bytes long.") 172 | 173 | self.__seed = seed 174 | 175 | @property 176 | def secret_type(self) -> SecretType: 177 | return SecretType.SEED 178 | 179 | @property 180 | def secret(self) -> bytes: 181 | return self.seed 182 | 183 | def as_priv(self) -> "IdentityKeyPairPriv": 184 | return IdentityKeyPairPriv(xeddsa.seed_to_priv(self.__seed)) 185 | 186 | @property 187 | def seed(self) -> bytes: 188 | """ 189 | Returns: 190 | The Curve25519/Ed25519 seed. 191 | """ 192 | 193 | return self.__seed 194 | -------------------------------------------------------------------------------- /x3dh/migrations.py: -------------------------------------------------------------------------------- 1 | # This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better 2 | from __future__ import annotations 3 | 4 | import base64 5 | from typing import List, Tuple, cast 6 | 7 | from pydantic import BaseModel 8 | 9 | from .models import IdentityKeyPairModel, SignedPreKeyPairModel, BaseStateModel 10 | from .types import JSONObject, SecretType 11 | 12 | 13 | __all__ = [ 14 | "parse_identity_key_pair_model", 15 | "parse_signed_pre_key_pair_model", 16 | "parse_base_state_model" 17 | ] 18 | 19 | 20 | class PreStableKeyPairModel(BaseModel): 21 | """ 22 | This model describes how a key pair was serialized in pre-stable serialization format. 23 | """ 24 | 25 | priv: str 26 | pub: str 27 | 28 | 29 | class PreStableSignedPreKeyModel(BaseModel): 30 | """ 31 | This model describes how a signed pre-key was serialized in pre-stable serialization format. 32 | """ 33 | 34 | key: PreStableKeyPairModel 35 | signature: str 36 | timestamp: float 37 | 38 | 39 | class PreStableModel(BaseModel): 40 | """ 41 | This model describes how State instances were serialized in pre-stable serialization format. 42 | """ 43 | 44 | changed: bool 45 | ik: PreStableKeyPairModel 46 | spk: PreStableSignedPreKeyModel 47 | otpks: List[PreStableKeyPairModel] 48 | 49 | 50 | def parse_identity_key_pair_model(serialized: JSONObject) -> IdentityKeyPairModel: 51 | """ 52 | Parse a serialized :class:`~x3dh.identity_key_pair.IdentityKeyPair` instance, as returned by 53 | :attr:`~x3dh.identity_key_pair.IdentityKeyPair.json`, into the most recent pydantic model available for 54 | the class. Perform migrations in case the pydantic models were updated. 55 | 56 | Args: 57 | serialized: The serialized instance. 58 | 59 | Returns: 60 | The model, which can be used to restore the instance using 61 | :meth:`~x3dh.identity_key_pair.IdentityKeyPair.from_model`. 62 | 63 | Note: 64 | Pre-stable data can only be migrated as a whole using :func:`parse_base_state_model`. 65 | """ 66 | 67 | # Each model has a Python string "version" in its root. Use that to find the model that the data was 68 | # serialized from. 69 | version = cast(str, serialized["version"]) 70 | model: BaseModel = { 71 | "1.0.0": IdentityKeyPairModel, 72 | "1.0.1": IdentityKeyPairModel 73 | }[version](**serialized) # type: ignore[arg-type] 74 | 75 | # Once all migrations have been applied, the model should be an instance of the most recent model 76 | assert isinstance(model, IdentityKeyPairModel) 77 | 78 | return model 79 | 80 | 81 | def parse_signed_pre_key_pair_model(serialized: JSONObject) -> SignedPreKeyPairModel: 82 | """ 83 | Parse a serialized :class:`~x3dh.signed_pre_key_pair.SignedPreKeyPair` instance, as returned by 84 | :attr:`~x3dh.signed_pre_key_pair.SignedPreKeyPair.json`, into the most recent pydantic model available for 85 | the class. Perform migrations in case the pydantic models were updated. 86 | 87 | Args: 88 | serialized: The serialized instance. 89 | 90 | Returns: 91 | The model, which can be used to restore the instance using 92 | :meth:`~x3dh.signed_pre_key_pair.SignedPreKeyPair.from_model`. 93 | 94 | Note: 95 | Pre-stable data can only be migrated as a whole using :func:`parse_base_state_model`. 96 | """ 97 | 98 | # Each model has a Python string "version" in its root. Use that to find the model that the data was 99 | # serialized from. 100 | version = cast(str, serialized["version"]) 101 | model: BaseModel = { 102 | "1.0.0": SignedPreKeyPairModel, 103 | "1.0.1": SignedPreKeyPairModel 104 | }[version](**serialized) # type: ignore[arg-type] 105 | 106 | # Once all migrations have been applied, the model should be an instance of the most recent model 107 | assert isinstance(model, SignedPreKeyPairModel) 108 | 109 | return model 110 | 111 | 112 | def parse_base_state_model(serialized: JSONObject) -> Tuple[BaseStateModel, bool]: 113 | """ 114 | Parse a serialized :class:`~x3dh.base_state.BaseState` instance, as returned by 115 | :attr:`~x3dh.base_state.BaseState.json`, into the most recent pydantic model available for the class. 116 | Perform migrations in case the pydantic models were updated. Supports migration of pre-stable data. 117 | 118 | Args: 119 | serialized: The serialized instance. 120 | 121 | Returns: 122 | The model, which can be used to restore the instance using 123 | :meth:`~x3dh.base_state.BaseState.from_model`, and a flag that indicates whether the bundle needs to 124 | be published, which was part of the pre-stable serialization format. 125 | """ 126 | 127 | bundle_needs_publish = False 128 | 129 | # Each model has a Python string "version" in its root. Use that to find the model that the data was 130 | # serialized from. Special case: the pre-stable serialization format does not contain a version. 131 | version = cast(str, serialized["version"]) if "version" in serialized else None 132 | model: BaseModel = { 133 | None: PreStableModel, 134 | "1.0.0": BaseStateModel, 135 | "1.0.1": BaseStateModel 136 | }[version](**serialized) 137 | 138 | if isinstance(model, PreStableModel): 139 | # Run migrations from PreStableModel to StateModel 140 | bundle_needs_publish = bundle_needs_publish or model.changed 141 | 142 | model = BaseStateModel( 143 | identity_key=IdentityKeyPairModel( 144 | secret=base64.b64decode(model.ik.priv), 145 | secret_type=SecretType.PRIV 146 | ), 147 | signed_pre_key=SignedPreKeyPairModel( 148 | priv=base64.b64decode(model.spk.key.priv), 149 | sig=base64.b64decode(model.spk.signature), 150 | timestamp=int(model.spk.timestamp) 151 | ), 152 | old_signed_pre_key=None, 153 | pre_keys=frozenset({ base64.b64decode(pre_key.priv) for pre_key in model.otpks }) 154 | ) 155 | 156 | # Once all migrations have been applied, the model should be an instance of the most recent model 157 | assert isinstance(model, BaseStateModel) 158 | 159 | return model, bundle_needs_publish 160 | -------------------------------------------------------------------------------- /x3dh/models.py: -------------------------------------------------------------------------------- 1 | from typing import Any, FrozenSet, Optional 2 | 3 | from pydantic import BaseModel 4 | from pydantic.functional_serializers import PlainSerializer 5 | from pydantic.functional_validators import PlainValidator 6 | from typing_extensions import Annotated 7 | 8 | from .types import SecretType 9 | 10 | 11 | __all__ = [ 12 | "BaseStateModel", 13 | "IdentityKeyPairModel", 14 | "SignedPreKeyPairModel" 15 | ] 16 | 17 | 18 | def _json_bytes_decoder(val: Any) -> bytes: 19 | """ 20 | Decode bytes from a string according to the JSON specification. See 21 | https://github.com/samuelcolvin/pydantic/issues/3756 for details. 22 | 23 | Args: 24 | val: The value to type check and decode. 25 | 26 | Returns: 27 | The value decoded to bytes. If the value is bytes already, it is returned unmodified. 28 | 29 | Raises: 30 | ValueError: if the value is not correctly encoded. 31 | """ 32 | 33 | if isinstance(val, bytes): 34 | return val 35 | if isinstance(val, str): 36 | return bytes(map(ord, val)) 37 | raise ValueError("bytes fields must be encoded as bytes or str.") 38 | 39 | 40 | def _json_bytes_encoder(val: bytes) -> str: 41 | """ 42 | Encode bytes as a string according to the JSON specification. See 43 | https://github.com/samuelcolvin/pydantic/issues/3756 for details. 44 | 45 | Args: 46 | val: The bytes to encode. 47 | 48 | Returns: 49 | The encoded bytes. 50 | """ 51 | 52 | return "".join(map(chr, val)) 53 | 54 | 55 | JsonBytes = Annotated[bytes, PlainValidator(_json_bytes_decoder), PlainSerializer(_json_bytes_encoder)] 56 | 57 | 58 | class IdentityKeyPairModel(BaseModel): 59 | """ 60 | The model representing the internal state of an :class:`~x3dh.identity_key_pair.IdentityKeyPair`. 61 | """ 62 | 63 | version: str = "1.0.0" 64 | secret: JsonBytes 65 | secret_type: SecretType 66 | 67 | 68 | class SignedPreKeyPairModel(BaseModel): 69 | """ 70 | The model representing the internal state of a :class:`~x3dh.signed_pre_key_pair.SignedPreKeyPair`. 71 | """ 72 | 73 | version: str = "1.0.0" 74 | priv: JsonBytes 75 | sig: JsonBytes 76 | timestamp: int 77 | 78 | 79 | class BaseStateModel(BaseModel): 80 | """ 81 | The model representing the internal state of a :class:`~x3dh.base_state.BaseState`. 82 | """ 83 | 84 | version: str = "1.0.0" 85 | identity_key: IdentityKeyPairModel 86 | signed_pre_key: SignedPreKeyPairModel 87 | old_signed_pre_key: Optional[SignedPreKeyPairModel] 88 | pre_keys: FrozenSet[JsonBytes] 89 | -------------------------------------------------------------------------------- /x3dh/pre_key_pair.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple 2 | 3 | import xeddsa 4 | 5 | 6 | __all__ = [ 7 | "PreKeyPair" 8 | ] 9 | 10 | 11 | class PreKeyPair(NamedTuple): 12 | """ 13 | A pre key. 14 | """ 15 | 16 | priv: bytes 17 | 18 | @property 19 | def pub(self) -> bytes: 20 | """ 21 | Returns: 22 | The public key of this pre key. 23 | """ 24 | 25 | return xeddsa.priv_to_curve25519_pub(self.priv) 26 | -------------------------------------------------------------------------------- /x3dh/project.py: -------------------------------------------------------------------------------- 1 | __all__ = [ "project" ] 2 | 3 | project = { 4 | "name" : "X3DH", 5 | "description" : "A Python implementation of the Extended Triple Diffie-Hellman key agreement protocol.", 6 | "url" : "https://github.com/Syndace/python-x3dh", 7 | "year" : "2024", 8 | "author" : "Tim Henkes (Syndace)", 9 | "author_email" : "me@syndace.dev", 10 | "categories" : [ 11 | "Topic :: Security :: Cryptography" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /x3dh/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Syndace/python-x3dh/fbb8c0ce5ede58a957e918bdafa3c6823ba80532/x3dh/py.typed -------------------------------------------------------------------------------- /x3dh/signed_pre_key_pair.py: -------------------------------------------------------------------------------- 1 | # This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better 2 | from __future__ import annotations 3 | 4 | import json 5 | from typing import NamedTuple, cast 6 | 7 | import xeddsa 8 | 9 | from .migrations import parse_signed_pre_key_pair_model 10 | from .models import SignedPreKeyPairModel 11 | from .types import JSONObject 12 | 13 | 14 | __all__ = [ 15 | "SignedPreKeyPair" 16 | ] 17 | 18 | 19 | class SignedPreKeyPair(NamedTuple): 20 | """ 21 | A signed pre key, i.e. a pre key whose public key was encoded using an application-specific encoding 22 | format, then signed by the identity key, and stored together with a generation timestamp for periodic 23 | rotation. 24 | """ 25 | 26 | priv: bytes 27 | sig: bytes 28 | timestamp: int 29 | 30 | @property 31 | def pub(self) -> bytes: 32 | """ 33 | Returns: 34 | The public key of this signed pre key. 35 | """ 36 | 37 | return xeddsa.priv_to_curve25519_pub(self.priv) 38 | 39 | @property 40 | def model(self) -> SignedPreKeyPairModel: 41 | """ 42 | Returns: 43 | The internal state of this :class:`SignedPreKeyPair` as a pydantic model. 44 | """ 45 | 46 | return SignedPreKeyPairModel(priv=self.priv, sig=self.sig, timestamp=self.timestamp) 47 | 48 | @property 49 | def json(self) -> JSONObject: 50 | """ 51 | Returns: 52 | The internal state of this :class:`SignedPreKeyPair` as a JSON-serializable Python object. 53 | """ 54 | 55 | return cast(JSONObject, json.loads(self.model.json())) 56 | 57 | @staticmethod 58 | def from_model(model: SignedPreKeyPairModel) -> "SignedPreKeyPair": 59 | """ 60 | Args: 61 | model: The pydantic model holding the internal state of a :class:`SignedPreKeyPair`, as produced 62 | by :attr:`model`. 63 | 64 | Returns: 65 | A configured instance of :class:`SignedPreKeyPair`, with internal state restored from the model. 66 | 67 | Warning: 68 | Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use 69 | :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the 70 | documentation for details. 71 | """ 72 | 73 | return SignedPreKeyPair(priv=model.priv, sig=model.sig, timestamp=model.timestamp) 74 | 75 | @staticmethod 76 | def from_json(serialized: JSONObject) -> "SignedPreKeyPair": 77 | """ 78 | Args: 79 | serialized: A JSON-serializable Python object holding the internal state of a 80 | :class:`SignedPreKeyPair`, as produced by :attr:`json`. 81 | 82 | Returns: 83 | A configured instance of :class:`SignedPreKeyPair`, with internal state restored from the 84 | serialized data. 85 | """ 86 | 87 | return SignedPreKeyPair.from_model(parse_signed_pre_key_pair_model(serialized)) 88 | -------------------------------------------------------------------------------- /x3dh/state.py: -------------------------------------------------------------------------------- 1 | # This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better 2 | from __future__ import annotations 3 | 4 | from abc import abstractmethod 5 | from typing import Optional, Tuple, Type, TypeVar 6 | 7 | from .base_state import BaseState 8 | from .crypto_provider import HashFunction 9 | from .identity_key_pair import IdentityKeyPair 10 | from .migrations import parse_base_state_model 11 | from .models import BaseStateModel 12 | from .signed_pre_key_pair import SignedPreKeyPair 13 | from .types import Bundle, IdentityKeyFormat, Header, JSONObject 14 | 15 | 16 | __all__ = [ 17 | "State" 18 | ] 19 | 20 | 21 | StateTypeT = TypeVar("StateTypeT", bound="State") 22 | 23 | 24 | class State(BaseState): 25 | """ 26 | This class is the core of this X3DH implementation. It manages the own :class:`~x3dh.types.Bundle` and 27 | offers methods to perform key agreements with other parties. Use :class:`~x3dh.base_state.BaseState` 28 | directly if manual state management is needed. Note that you can still use the methods available for 29 | manual state management, but doing so shouldn't be required. 30 | 31 | Warning: 32 | :meth:`rotate_signed_pre_key` should be called periodically to check whether the signed pre key needs 33 | to be rotated and to perform the rotation if necessary. 34 | """ 35 | 36 | def __init__(self) -> None: 37 | super().__init__() 38 | 39 | # Just the type definitions here 40 | self.__signed_pre_key_rotation_period: int 41 | self.__pre_key_refill_threshold: int 42 | self.__pre_key_refill_target: int 43 | 44 | @classmethod 45 | def create( 46 | cls: Type[StateTypeT], 47 | identity_key_format: IdentityKeyFormat, 48 | hash_function: HashFunction, 49 | info: bytes, 50 | identity_key_pair: Optional[IdentityKeyPair] = None, 51 | signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60, 52 | pre_key_refill_threshold: int = 99, 53 | pre_key_refill_target: int = 100 54 | ) -> StateTypeT: 55 | """ 56 | Args: 57 | identity_key_format: The format in which the identity public key is included in bundles/headers. 58 | hash_function: A 256 or 512-bit hash function. 59 | info: A (byte) string identifying the application. 60 | signed_pre_key_rotation_period: Rotate the signed pre key after this amount of time in seconds. 61 | pre_key_refill_threshold: Threshold for refilling the pre keys. 62 | pre_key_refill_target: When less then ``pre_key_refill_threshold`` pre keys are available, 63 | generate new ones until there are ``pre_key_refill_target`` pre keys again. 64 | identity_key_pair: If set, use the given identity key pair instead of generating a new one. 65 | 66 | Returns: 67 | A configured instance of :class:`~x3dh.state.State`. 68 | """ 69 | # pylint: disable=protected-access 70 | 71 | if signed_pre_key_rotation_period < 1: 72 | raise ValueError( 73 | "Invalid value passed for the `signed_pre_key_rotation_period` parameter. The signed pre key" 74 | " rotation period must be at least one day." 75 | ) 76 | 77 | if not 1 <= pre_key_refill_threshold <= pre_key_refill_target: 78 | raise ValueError( 79 | "Invalid value(s) passed for the `pre_key_refill_threshold` / `pre_key_refill_target`" 80 | " parameter(s). `pre_key_refill_threshold` must be greater than or equal to '1' and lower" 81 | " than or equal to `pre_key_refill_target`." 82 | ) 83 | 84 | self = super().create(identity_key_format, hash_function, info, identity_key_pair) 85 | 86 | self.__signed_pre_key_rotation_period = signed_pre_key_rotation_period 87 | self.__pre_key_refill_threshold = pre_key_refill_threshold 88 | self.__pre_key_refill_target = pre_key_refill_target 89 | 90 | self.generate_pre_keys(pre_key_refill_target) 91 | 92 | # I believe this is a false positive by pylint 93 | self._publish_bundle(self.bundle) # pylint: disable=no-member 94 | 95 | return self 96 | 97 | #################### 98 | # abstract methods # 99 | #################### 100 | 101 | @abstractmethod 102 | def _publish_bundle(self, bundle: Bundle) -> None: 103 | """ 104 | Args: 105 | bundle: The bundle to publish, overwriting previously published data. 106 | 107 | Note: 108 | In addition to publishing the bundle, this method can be used as a trigger to persist the state. 109 | Persisting the state in this method guarantees always remaining up-to-date. 110 | 111 | Note: 112 | This method is called from :meth:`create`, before :meth:`create` has returned the instance. Thus, 113 | modifications to the object (``self``, in case of subclasses) may not have happened when this 114 | method is called. 115 | 116 | Note: 117 | Even though this method is expected to perform I/O, it is deliberately not marked as async, since 118 | completion of the I/O operation is not a requirement for the program flow to continue, and making 119 | this method async would complicate API design with regards to inheritance from 120 | :class:`~x3dh.base_state.BaseState`. 121 | """ 122 | 123 | raise NotImplementedError("Create a subclass of State and implement `_publish_bundle`.") 124 | 125 | ################# 126 | # serialization # 127 | ################# 128 | 129 | @classmethod 130 | def from_model( 131 | cls: Type[StateTypeT], 132 | model: BaseStateModel, 133 | identity_key_format: IdentityKeyFormat, 134 | hash_function: HashFunction, 135 | info: bytes, 136 | signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60, 137 | pre_key_refill_threshold: int = 99, 138 | pre_key_refill_target: int = 100 139 | ) -> StateTypeT: 140 | """ 141 | Args: 142 | model: The pydantic model holding the internal state of a :class:`State`, as produced by 143 | :attr:`~x3dh.base_state.BaseState.model`. 144 | identity_key_format: The format in which the identity public key is included in bundles/headers. 145 | hash_function: A 256 or 512-bit hash function. 146 | info: A (byte) string identifying the application. 147 | signed_pre_key_rotation_period: Rotate the signed pre key after this amount of time in seconds. 148 | pre_key_refill_threshold: Threshold for refilling the pre keys. 149 | pre_key_refill_target: When less then ``pre_key_refill_threshold`` pre keys are available, 150 | generate new ones until there are ``pre_key_refill_target`` pre keys again. 151 | 152 | Returns: 153 | A configured instance of :class:`State`, with internal state restored from the model. 154 | 155 | Warning: 156 | Migrations are not provided via the :attr:`~x3dh.base_state.BaseState.model`/:meth:`from_model` 157 | API. Use :attr:`~x3dh.base_state.BaseState.json`/:meth:`from_json` instead. Refer to 158 | :ref:`serialization_and_migration` in the documentation for details. 159 | """ 160 | # pylint: disable=protected-access 161 | 162 | if signed_pre_key_rotation_period < 1: 163 | raise ValueError( 164 | "Invalid value passed for the `signed_pre_key_rotation_period` parameter. The signed pre key" 165 | " rotation period must be at least one day." 166 | ) 167 | 168 | if not 1 <= pre_key_refill_threshold <= pre_key_refill_target: 169 | raise ValueError( 170 | "Invalid value(s) passed for the `pre_key_refill_threshold` / `pre_key_refill_target`" 171 | " parameter(s). `pre_key_refill_threshold` must be greater than or equal to '1' and lower" 172 | " than or equal to `pre_key_refill_target`." 173 | ) 174 | 175 | self = super().from_model(model, identity_key_format, hash_function, info) 176 | 177 | self.__signed_pre_key_rotation_period = signed_pre_key_rotation_period 178 | self.__pre_key_refill_threshold = pre_key_refill_threshold 179 | self.__pre_key_refill_target = pre_key_refill_target 180 | 181 | self.rotate_signed_pre_key() 182 | 183 | return self 184 | 185 | @classmethod 186 | def from_json( 187 | cls: Type[StateTypeT], 188 | serialized: JSONObject, 189 | identity_key_format: IdentityKeyFormat, 190 | hash_function: HashFunction, 191 | info: bytes, 192 | signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60, 193 | pre_key_refill_threshold: int = 99, 194 | pre_key_refill_target: int = 100 195 | ) -> Tuple[StateTypeT, bool]: 196 | """ 197 | Args: 198 | serialized: A JSON-serializable Python object holding the internal state of a :class:`State`, 199 | as produced by :attr:`~x3dh.base_state.BaseState.json`. 200 | identity_key_format: The format in which the identity public key is included in bundles/headers. 201 | hash_function: A 256 or 512-bit hash function. 202 | info: A (byte) string identifying the application. 203 | signed_pre_key_rotation_period: Rotate the signed pre key after this amount of time in seconds. 204 | pre_key_refill_threshold: Threshold for refilling the pre keys. 205 | pre_key_refill_target: When less then ``pre_key_refill_threshold`` pre keys are available, 206 | generate new ones until there are ``pre_key_refill_target`` pre keys again. 207 | 208 | Returns: 209 | A configured instance of :class:`State`, with internal state restored from the serialized data, 210 | and a flag that indicates whether the bundle needed to be published. The latter was part of the 211 | pre-stable serialization format and is handled automatically by this :meth:`from_json` 212 | implementation. 213 | """ 214 | # pylint: disable=protected-access 215 | 216 | model, bundle_needs_publish = parse_base_state_model(serialized) 217 | 218 | self = cls.from_model( 219 | model, 220 | identity_key_format, 221 | hash_function, 222 | info, 223 | signed_pre_key_rotation_period, 224 | pre_key_refill_threshold, 225 | pre_key_refill_target 226 | ) 227 | 228 | if bundle_needs_publish: 229 | # I believe this is a false positive by pylint 230 | self._publish_bundle(self.bundle) # pylint: disable=no-member 231 | 232 | return self, False 233 | 234 | ################################# 235 | # key generation and management # 236 | ################################# 237 | 238 | def rotate_signed_pre_key(self, force: bool = False) -> None: 239 | """ 240 | Check whether the signed pre key is due for rotation, and rotate it if necessary. Call this method 241 | periodically to make sure the signed pre key is always up to date. 242 | 243 | Args: 244 | force: Whether to force rotation regardless of the age of the current signed pre key. 245 | """ 246 | 247 | if force or self.signed_pre_key_age() > self.__signed_pre_key_rotation_period: 248 | super().rotate_signed_pre_key() 249 | 250 | self._publish_bundle(self.bundle) 251 | 252 | ################# 253 | # key agreement # 254 | ################# 255 | 256 | async def get_shared_secret_passive( 257 | self, 258 | header: Header, 259 | associated_data_appendix: bytes = b"", 260 | require_pre_key: bool = True 261 | ) -> Tuple[bytes, bytes, SignedPreKeyPair]: 262 | """ 263 | Perform an X3DH key agreement, passively. 264 | 265 | Args: 266 | header: The header received from the active party. 267 | associated_data_appendix: Additional information to append to the associated data, like usernames, 268 | certificates or other identifying information. 269 | require_pre_key: Use this flag to abort the key agreement if the active party did not use a pre 270 | key. 271 | 272 | Returns: 273 | The shared secret and the associated data shared between both parties, and the signed pre key pair 274 | that was used during the key exchange, for use by follow-up protocols. 275 | 276 | Raises: 277 | KeyAgreementException: If an error occurs during the key agreement. The exception message will 278 | contain (human-readable) details. 279 | """ 280 | 281 | shared_secret, associated_data, signed_pre_key_pair = await super().get_shared_secret_passive( 282 | header, 283 | associated_data_appendix, 284 | require_pre_key 285 | ) 286 | 287 | # If a pre key was used, remove it from the pool and refill the pool if necessary 288 | if header.pre_key is not None: 289 | self.delete_pre_key(header.pre_key) 290 | 291 | if self.get_num_visible_pre_keys() < self.__pre_key_refill_threshold: 292 | self.generate_pre_keys(self.__pre_key_refill_target - self.get_num_visible_pre_keys()) 293 | 294 | self._publish_bundle(self.bundle) 295 | 296 | return shared_secret, associated_data, signed_pre_key_pair 297 | -------------------------------------------------------------------------------- /x3dh/types.py: -------------------------------------------------------------------------------- 1 | # This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better 2 | from __future__ import annotations 3 | 4 | import enum 5 | from typing import FrozenSet, List, Mapping, NamedTuple, Optional, Union 6 | 7 | 8 | __all__ = [ 9 | "Bundle", 10 | "IdentityKeyFormat", 11 | "Header", 12 | "JSONObject", 13 | "SecretType" 14 | ] 15 | 16 | 17 | ################ 18 | # Type Aliases # 19 | ################ 20 | 21 | # # Thanks @vanburgerberg - https://github.com/python/typing/issues/182 22 | # if TYPE_CHECKING: 23 | # class JSONArray(list[JSONType], Protocol): # type: ignore 24 | # __class__: Type[list[JSONType]] # type: ignore 25 | # 26 | # class JSONObject(dict[str, JSONType], Protocol): # type: ignore 27 | # __class__: Type[dict[str, JSONType]] # type: ignore 28 | # 29 | # JSONType = Union[None, float, int, str, bool, JSONArray, JSONObject] 30 | 31 | # Sadly @vanburgerberg's solution doesn't seem to like Dict[str, bool], thus for now an incomplete JSON 32 | # type with finite levels of depth. 33 | Primitives = Union[None, float, int, str, bool] 34 | JSONType1 = Union[Primitives, List[Primitives], Mapping[str, Primitives]] 35 | JSONType = Union[Primitives, List[JSONType1], Mapping[str, JSONType1]] 36 | JSONObject = Mapping[str, JSONType] 37 | 38 | 39 | ############################ 40 | # Structures (NamedTuples) # 41 | ############################ 42 | 43 | class Bundle(NamedTuple): 44 | """ 45 | The bundle is a collection of public keys and signatures used by the X3DH protocol to achieve asynchronous 46 | key agreements while providing forward secrecy and cryptographic deniability. Parties that want to be 47 | available for X3DH key agreements have to publish their bundle somehow. Other parties can then use that 48 | bundle to perform a key agreement. 49 | """ 50 | 51 | identity_key: bytes 52 | signed_pre_key: bytes 53 | signed_pre_key_sig: bytes 54 | pre_keys: FrozenSet[bytes] 55 | 56 | 57 | class Header(NamedTuple): 58 | """ 59 | The header generated by the active party as part of the key agreement, and consumed by the passive party 60 | to derive the same shared secret. 61 | """ 62 | 63 | identity_key: bytes 64 | ephemeral_key: bytes 65 | signed_pre_key: bytes 66 | pre_key: Optional[bytes] 67 | 68 | 69 | ################ 70 | # Enumerations # 71 | ################ 72 | 73 | @enum.unique 74 | class IdentityKeyFormat(enum.Enum): 75 | """ 76 | The two supported public key formats for the identity key: 77 | 78 | * Curve25519 public keys: 32 bytes, the little-endian encoding of the u coordinate as per `RFC 7748, 79 | section 5 "The X25519 and X448 Functions" `_. 80 | * Ed25519 public keys: 32 bytes, the little-endian encoding of the y coordinate with the sign bit of the x 81 | coordinate stored in the most significant bit as per `RFC 8032, section 3.2 "Keys" 82 | `_. 83 | """ 84 | 85 | CURVE_25519: str = "CURVE_25519" 86 | ED_25519: str = "ED_25519" 87 | 88 | 89 | @enum.unique 90 | class SecretType(enum.Enum): 91 | """ 92 | The two types of secrets that an :class:`IdentityKeyPair` can use internally: a seed or a private key. 93 | """ 94 | 95 | SEED: str = "SEED" 96 | PRIV: str = "PRIV" 97 | -------------------------------------------------------------------------------- /x3dh/version.py: -------------------------------------------------------------------------------- 1 | __all__ = [ "__version__" ] 2 | 3 | __version__ = {} 4 | __version__["short"] = "1.1.0" 5 | __version__["tag"] = "stable" 6 | __version__["full"] = f"{__version__['short']}-{__version__['tag']}" 7 | --------------------------------------------------------------------------------