├── .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 | [](https://pypi.org/project/X3DH/)
2 | [](https://pypi.org/project/X3DH/)
3 | [](https://github.com/Syndace/python-x3dh/actions/workflows/test-and-publish.yml)
4 | [](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 |
--------------------------------------------------------------------------------