pax_global_header00006660000000000000000000000064143324542340014516gustar00rootroot0000000000000052 comment=8120301120c6383db3efea76f734c644525d9914 python-x3dh-1.0.3/000077500000000000000000000000001433245423400137045ustar00rootroot00000000000000python-x3dh-1.0.3/.flake8000066400000000000000000000001641433245423400150600ustar00rootroot00000000000000[flake8] max-line-length = 110 doctests = True ignore = E201,E202,W503 per-file-ignores = x3dh/project.py:E203 python-x3dh-1.0.3/.github/000077500000000000000000000000001433245423400152445ustar00rootroot00000000000000python-x3dh-1.0.3/.github/workflows/000077500000000000000000000000001433245423400173015ustar00rootroot00000000000000python-x3dh-1.0.3/.github/workflows/test-and-publish.yml000066400000000000000000000035151433245423400232130ustar00rootroot00000000000000name: Test & Publish on: [push, pull_request] permissions: contents: read jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "pypy-3.9"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install/update package management dependencies run: python -m pip install --upgrade pip setuptools wheel - name: Build and install python-x3dh run: pip install . - name: Install test dependencies run: pip install --upgrade pytest pytest-asyncio pytest-cov mypy pylint flake8 - name: Type-check using mypy run: mypy --strict x3dh/ setup.py tests/ - name: Lint using pylint run: pylint x3dh/ setup.py tests/ - name: Format-check using Flake8 run: flake8 x3dh/ setup.py tests/ - name: Test using pytest run: pytest --cov=x3dh --cov-report term-missing:skip-covered build: name: Build source distribution and wheel runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Build source distribution and wheel run: python3 setup.py sdist bdist_wheel - uses: actions/upload-artifact@v3 with: path: | dist/*.tar.gz dist/*.whl publish: needs: [test, build] runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') steps: - uses: actions/download-artifact@v3 with: name: artifact path: dist - uses: pypa/gh-action-pypi-publish@v1.5.1 with: user: __token__ password: ${{ secrets.pypi_token }} python-x3dh-1.0.3/.gitignore000066400000000000000000000001271433245423400156740ustar00rootroot00000000000000dist/ X3DH.egg-info/ __pycache__/ .pytest_cache/ .mypy_cache/ .coverage docs/_build/ python-x3dh-1.0.3/.readthedocs.yaml000066400000000000000000000003721433245423400171350ustar00rootroot00000000000000version: 2 build: os: ubuntu-22.04 tools: python: "3" apt_packages: - libsodium-dev sphinx: configuration: docs/conf.py fail_on_warning: true python: install: - requirements: docs/requirements.txt - method: pip path: . python-x3dh-1.0.3/CHANGELOG.md000066400000000000000000000021371433245423400155200ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [1.0.3] - 8th of November 2022 ### Changed - Exclude tests from the packages ## [1.0.2] - 5th of November 2022 ### Changed - Fixed a bug in the way the storage models were versioned ## [1.0.1] - 3rd of November 2022 ### Added - Python 3.11 to the list of supported versions ## [1.0.0] - 1st of November 2022 ### Added - Rewrite for modern, type safe Python 3. ### Removed - Pre-stable (i.e. versions before 1.0.0) changelog omitted. [Unreleased]: https://github.com/Syndace/python-doubleratchet/compare/v1.0.3...HEAD [1.0.3]: https://github.com/Syndace/python-doubleratchet/compare/v1.0.2...v1.0.3 [1.0.2]: https://github.com/Syndace/python-doubleratchet/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/Syndace/python-doubleratchet/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/Syndace/python-doubleratchet/releases/tag/v1.0.0 python-x3dh-1.0.3/LICENSE000066400000000000000000000020711433245423400147110ustar00rootroot00000000000000The MIT License Copyright (c) 2022 Tim Henkes (Syndace) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-x3dh-1.0.3/MANIFEST.in000066400000000000000000000000261433245423400154400ustar00rootroot00000000000000include x3dh/py.typed python-x3dh-1.0.3/README.md000066400000000000000000000043311433245423400151640ustar00rootroot00000000000000[![PyPI](https://img.shields.io/pypi/v/X3DH.svg)](https://pypi.org/project/X3DH/) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/X3DH.svg)](https://pypi.org/project/X3DH/) [![Build Status](https://github.com/Syndace/python-x3dh/actions/workflows/test-and-publish.yml/badge.svg)](https://github.com/Syndace/python-x3dh/actions/workflows/test-and-publish.yml) [![Documentation Status](https://readthedocs.org/projects/python-x3dh/badge/?version=latest)](https://python-x3dh.readthedocs.io/) # python-x3dh # A Python implementation of the [Extended Triple Diffie-Hellman key agreement protocol](https://signal.org/docs/specifications/x3dh/). ## Installation ## Install the latest release using pip (`pip install X3DH`) or manually from source by running `pip install .` in the cloned repository. ## Differences to the Specification ## 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. ## Testing, Type Checks and Linting ## 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: ```sh $ pip install --upgrade pytest pytest-asyncio pytest-cov mypy pylint flake8 $ mypy --strict x3dh/ setup.py tests/ $ pylint x3dh/ setup.py tests/ $ flake8 x3dh/ setup.py tests/ $ pytest --cov=x3dh --cov-report term-missing:skip-covered ``` ## Documentation ## 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/`. python-x3dh-1.0.3/docs/000077500000000000000000000000001433245423400146345ustar00rootroot00000000000000python-x3dh-1.0.3/docs/Makefile000066400000000000000000000011321433245423400162710ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = X3DH SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) python-x3dh-1.0.3/docs/_static/000077500000000000000000000000001433245423400162625ustar00rootroot00000000000000python-x3dh-1.0.3/docs/_static/.gitkeep000066400000000000000000000000001433245423400177010ustar00rootroot00000000000000python-x3dh-1.0.3/docs/conf.py000066400000000000000000000064421433245423400161410ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file does only contain a selection of the most common options. For a full list see # the documentation: # http://www.sphinx-doc.org/en/master/config # -- Path setup -------------------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, add these # directories to sys.path here. If the directory is relative to the documentation root, # use os.path.abspath to make it absolute, like shown here. import os import sys this_file_path = os.path.dirname(os.path.abspath(__file__)) sys.path.append(os.path.join(this_file_path, "..", "x3dh")) from version import __version__ as __version from project import project as __project # -- Project information ----------------------------------------------------------------- project = __project["name"] author = __project["author"] copyright = f"{__project['year']}, {__project['author']}" # The short X.Y version version = __version["short"] # The full version, including alpha/beta/rc tags release = __version["full"] # -- General configuration --------------------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions coming # with Sphinx (named "sphinx.ext.*") or your custom ones. extensions = [ "sphinx.ext.autodoc", "sphinx.ext.viewcode", "sphinx.ext.napoleon", "sphinx_autodoc_typehints" ] # Add any paths that contain templates here, relative to this directory. templates_path = [ "_templates" ] # List of patterns, relative to source directory, that match files and directories to # ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [ "_build", "Thumbs.db", ".DS_Store" ] # -- Options for HTML output ------------------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for a list of # builtin themes. html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, relative to # this directory. They are copied after the builtin static files, so a file named # "default.css" will overwrite the builtin "default.css". html_static_path = [ "_static" ] # -- Autodoc Configuration --------------------------------------------------------------- # The following two options seem to be ignored... autodoc_typehints = "description" autodoc_type_aliases = { type_alias: f"{type_alias}" for type_alias in { "JSONObject" } } def autodoc_skip_member_handler(app, what, name, obj, skip, options): # Skip private members, i.e. those that start with double underscores but do not end in underscores if name.startswith("__") and not name.endswith("_"): return True # Could be achieved using exclude-members, but this is more comfy if name in { "__abstractmethods__", "__dict__", "__module__", "__new__", "__weakref__", "_abc_impl" }: return True # Skip __init__s without documentation. Those are just used for type hints. if name == "__init__" and obj.__doc__ is None: return True return None def setup(app): app.connect("autodoc-skip-member", autodoc_skip_member_handler) python-x3dh-1.0.3/docs/getting_started.rst000066400000000000000000000041001433245423400205500ustar00rootroot00000000000000Getting Started =============== This quick start guide assumes basic knowledge of the `X3DH key agreement protocol `__. 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. .. _ik-types: 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. python-x3dh-1.0.3/docs/index.rst000066400000000000000000000005001433245423400164700ustar00rootroot00000000000000python-x3dh - A Python implementation of the Extended Triple Diffie-Hellman key agreement protocol. =================================================================================================== .. toctree:: installation getting_started serialization_and_migration API Documentation python-x3dh-1.0.3/docs/installation.rst000066400000000000000000000002461433245423400200710ustar00rootroot00000000000000Installation ============ Install the latest release using pip (``pip install X3DH``) or manually from source by running ``pip install .`` in the cloned repository. python-x3dh-1.0.3/docs/make.bat000066400000000000000000000014501433245423400162410ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build set SPHINXPROJ=X3DH if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd python-x3dh-1.0.3/docs/requirements.txt000066400000000000000000000000611433245423400201150ustar00rootroot00000000000000sphinx sphinx-rtd-theme sphinx-autodoc-typehints python-x3dh-1.0.3/docs/serialization_and_migration.rst000066400000000000000000000022061433245423400231360ustar00rootroot00000000000000.. _serialization_and_migration: Serialization and Migration =========================== 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. Migration from pre-stable ------------------------- 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. python-x3dh-1.0.3/docs/x3dh/000077500000000000000000000000001433245423400155025ustar00rootroot00000000000000python-x3dh-1.0.3/docs/x3dh/base_state.rst000066400000000000000000000003101433245423400203400ustar00rootroot00000000000000Module: base_state ================== .. automodule:: x3dh.base_state :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-x3dh-1.0.3/docs/x3dh/identity_key_pair.rst000066400000000000000000000003351433245423400217510ustar00rootroot00000000000000Module: identity_key_pair ========================= .. automodule:: x3dh.identity_key_pair :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-x3dh-1.0.3/docs/x3dh/migrations.rst000066400000000000000000000003101433245423400204020ustar00rootroot00000000000000Module: migrations ================== .. automodule:: x3dh.migrations :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-x3dh-1.0.3/docs/x3dh/models.rst000066400000000000000000000002201433245423400175110ustar00rootroot00000000000000Module: models ============== .. automodule:: x3dh.models :members: :undoc-members: :member-order: bysource :show-inheritance: python-x3dh-1.0.3/docs/x3dh/package.rst000066400000000000000000000005221433245423400176260ustar00rootroot00000000000000Package: x3dh ============= .. toctree:: Module: base_state Module: identity_key_pair Module: migrations Module: models Module: pre_key_pair Module: signed_pre_key_pair Module: state Module: types python-x3dh-1.0.3/docs/x3dh/pre_key_pair.rst000066400000000000000000000002421433245423400207030ustar00rootroot00000000000000Module: pre_key_pair ==================== .. automodule:: x3dh.pre_key_pair :members: :undoc-members: :member-order: bysource :show-inheritance: python-x3dh-1.0.3/docs/x3dh/signed_pre_key_pair.rst000066400000000000000000000002671433245423400222430ustar00rootroot00000000000000Module: signed_pre_key_pair =========================== .. automodule:: x3dh.signed_pre_key_pair :members: :undoc-members: :member-order: bysource :show-inheritance: python-x3dh-1.0.3/docs/x3dh/state.rst000066400000000000000000000002711433245423400173540ustar00rootroot00000000000000Module: state ============= .. automodule:: x3dh.state :members: :special-members: :private-members: :undoc-members: :member-order: bysource :show-inheritance: python-x3dh-1.0.3/docs/x3dh/types.rst000066400000000000000000000002151433245423400173760ustar00rootroot00000000000000Module: types ============= .. automodule:: x3dh.types :members: :undoc-members: :member-order: bysource :show-inheritance: python-x3dh-1.0.3/pylintrc000066400000000000000000000362571433245423400155100ustar00rootroot00000000000000[MASTER] # A comma-separated list of package or module names from where C extensions may # be loaded. Extensions are loading into the active Python interpreter and may # run arbitrary code. extension-pkg-whitelist=pydantic # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. ignore-patterns= # Python code to execute, usually for sys.path manipulation such as # pygtk.require(). #init-hook= # Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the # number of processors available to use. jobs=1 # Control the amount of potential inferred values when inferring a single # object. This can help the performance when dealing with large functions or # complex, nested conditions. limit-inference-results=100 # List of plugins (as comma separated values of python module names) to load, # usually to register additional checkers. load-plugins= # Pickle collected data for later comparisons. persistent=yes # Specify a configuration file. #rcfile= # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. confidence= # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once). You can also use "--disable=all" to # disable everything first and then reenable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use "--disable=all --enable=classes # --disable=W". disable=missing-module-docstring, duplicate-code, fixme, logging-fstring-interpolation # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time (only on the command line, not in the configuration file where # it should appear only once). See also the "--disable" option for examples. enable=useless-suppression [REPORTS] # Python expression which should return a score less than or equal to 10. You # have access to the variables 'error', 'warning', 'refactor', and 'convention' # which contain the number of messages in each category, as well as 'statement' # which is the total number of statements analyzed. This score is used by the # global evaluation report (RP0004). evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) # Template used to display messages. This is a python new-style format string # used to format the message information. See doc for all details. #msg-template= # Set the output format. Available formats are text, parseable, colorized, json # and msvs (visual studio). You can also give a reporter class, e.g. # mypackage.mymodule.MyReporterClass. output-format=text # Tells whether to display a full report or only the messages. reports=no # Activate the evaluation score. score=yes [REFACTORING] # Maximum number of nested blocks for function / method body max-nested-blocks=5 # Complete name of functions that never returns. When checking for # inconsistent-return-statements if a never returning function is called then # it will be considered as an explicit return statement and no message will be # printed. never-returning-functions=sys.exit [SPELLING] # Limits count of emitted suggestions for spelling mistakes. max-spelling-suggestions=4 # Spelling dictionary name. Available dictionaries: none. To make it work, # install the python-enchant package. spelling-dict= # List of comma separated words that should not be checked. spelling-ignore-words= # A path to a file that contains the private dictionary; one word per line. spelling-private-dict-file= # Tells whether to store unknown words to the private dictionary (see the # --spelling-private-dict-file option) instead of raising a message. spelling-store-unknown-words=no [VARIABLES] # List of additional names supposed to be defined in builtins. Remember that # you should avoid defining new builtins when possible. additional-builtins= # Tells whether unused global variables should be treated as a violation. allow-global-unused-variables=no # List of strings which can identify a callback function by name. A callback # name must start or end with one of those strings. callbacks=cb_, _cb # A regular expression matching the name of dummy variables (i.e. expected to # not be used). dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ # Argument names that match this expression will be ignored. Default to name # with leading underscore. ignored-argument-names=_.*|^ignored_|^unused_ # Tells whether we should check for unused import in __init__ files. init-import=no # List of qualified module names which can have objects that can redefine # builtins. redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io [LOGGING] # Format style used to check logging format string. `old` means using % # formatting, `new` is for `{}` formatting,and `fstr` is for f-strings. logging-format-style=old # Logging modules to check that the string format arguments are in logging # function parameter format. logging-modules=logging [FORMAT] # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. expected-line-ending-format= # Regexp for a line that is allowed to be longer than the limit. ignore-long-lines=^\s*(# )??$ # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). indent-string=' ' # Maximum number of characters on a single line. max-line-length=110 # Maximum number of lines in a module. max-module-lines=10000 # Allow the body of a class to be on the same line as the declaration if body # contains single statement. single-line-class-stmt=no # Allow the body of an if to be on the same line as the test if there is no # else. single-line-if-stmt=yes [TYPECHECK] # List of decorators that produce context managers, such as # contextlib.contextmanager. Add to this list to register other decorators that # produce valid context managers. contextmanager-decorators=contextlib.contextmanager # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular # expressions are accepted. generated-members= # Tells whether missing members accessed in mixin class should be ignored. A # mixin class is detected if its name ends with "mixin" (case insensitive). ignore-mixin-members=yes # Tells whether to warn about missing members when the owner of the attribute # is inferred to be None. ignore-none=no # This flag controls whether pylint should warn about no-member and similar # checks whenever an opaque object is returned when inferring. The inference # can return multiple potential results while evaluating a Python object, but # some branches might not be evaluated, which results in partial inference. In # that case, it might be useful to still emit no-member and other checks for # the rest of the inferred objects. ignore-on-opaque-inference=no # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. ignored-classes=optparse.Values,thread._local,_thread._local # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis). It # supports qualified module names, as well as Unix pattern matching. ignored-modules= # Show a hint with possible names when a member name was not found. The aspect # of finding the hint is based on edit distance. missing-member-hint=yes # The minimum edit distance a name should have in order to be considered a # similar match for a missing member name. missing-member-hint-distance=1 # The total number of similar names that should be taken in consideration when # showing a hint for a missing member. missing-member-max-choices=1 # List of decorators that change the signature of a decorated function. signature-mutators= [STRING] # This flag controls whether the implicit-str-concat-in-sequence should # generate a warning on implicit string concatenation in sequences defined over # several lines. check-str-concat-over-line-jumps=no [SIMILARITIES] # Ignore comments when computing similarities. ignore-comments=yes # Ignore docstrings when computing similarities. ignore-docstrings=yes # Ignore imports when computing similarities. ignore-imports=no # Minimum lines number of a similarity. min-similarity-lines=4 [BASIC] # Naming style matching correct argument names. argument-naming-style=snake_case # Regular expression matching correct argument names. Overrides argument- # naming-style. #argument-rgx= # Naming style matching correct attribute names. attr-naming-style=snake_case # Regular expression matching correct attribute names. Overrides attr-naming- # style. #attr-rgx= # Bad variable names which should always be refused, separated by a comma. bad-names=foo, bar, baz, toto, tutu, tata # Naming style matching correct class attribute names. class-attribute-naming-style=snake_case # Regular expression matching correct class attribute names. Overrides class- # attribute-naming-style. #class-attribute-rgx= # Naming style matching correct class names. class-naming-style=PascalCase # Regular expression matching correct class names. Overrides class-naming- # style. #class-rgx= # Naming style matching correct constant names. const-naming-style=any # Regular expression matching correct constant names. Overrides const-naming- # style. #const-rgx= # Minimum line length for functions/classes that require docstrings, shorter # ones are exempt. docstring-min-length=-1 # Naming style matching correct function names. function-naming-style=snake_case # Regular expression matching correct function names. Overrides function- # naming-style. #function-rgx= # Good variable names which should always be accepted, separated by a comma. good-names=i, j, e, # exceptions in except blocks _ # Include a hint for the correct naming format with invalid-name. include-naming-hint=no # Naming style matching correct inline iteration names. inlinevar-naming-style=any # Regular expression matching correct inline iteration names. Overrides # inlinevar-naming-style. #inlinevar-rgx= # Naming style matching correct method names. method-naming-style=snake_case # Regular expression matching correct method names. Overrides method-naming- # style. #method-rgx= # Naming style matching correct module names. module-naming-style=snake_case # Regular expression matching correct module names. Overrides module-naming- # style. #module-rgx= # Colon-delimited sets of names that determine each other's naming style when # the name regexes allow several styles. name-group= # Regular expression which should only match function or class names that do # not require a docstring. no-docstring-rgx=^_ # List of decorators that produce properties, such as abc.abstractproperty. Add # to this list to register other decorators that produce valid properties. # These decorators are taken in consideration only for invalid-name. property-classes=abc.abstractproperty # Naming style matching correct variable names. variable-naming-style=snake_case # Regular expression matching correct variable names. Overrides variable- # naming-style. #variable-rgx= [MISCELLANEOUS] # List of note tags to take in consideration, separated by a comma. notes=FIXME, XXX, TODO [IMPORTS] # List of modules that can be imported at any level, not just the top level # one. allow-any-import-level= # Allow wildcard imports from modules that define __all__. allow-wildcard-with-all=no # Analyse import fallback blocks. This can be used to support both Python 2 and # 3 compatible code, which means that the block might have code that exists # only in one or another interpreter, leading to false positives when analysed. analyse-fallback-blocks=yes # Deprecated modules which should not be used, separated by a comma. deprecated-modules=optparse,tkinter.tix # Create a graph of external dependencies in the given file (report RP0402 must # not be disabled). ext-import-graph= # Create a graph of every (i.e. internal and external) dependencies in the # given file (report RP0402 must not be disabled). import-graph= # Create a graph of internal dependencies in the given file (report RP0402 must # not be disabled). int-import-graph= # Force import order to recognize a module as part of the standard # compatibility libraries. known-standard-library= # Force import order to recognize a module as part of a third party library. known-third-party=enchant # Couples of modules and preferred modules, separated by a comma. preferred-modules= [CLASSES] # List of method names used to declare (i.e. assign) instance attributes. defining-attr-methods=__init__, __new__, setUp, __post_init__, create # List of member names, which should be excluded from the protected access # warning. exclude-protected=_asdict, _fields, _replace, _source, _make # List of valid names for the first argument in a class method. valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=cls [DESIGN] # Maximum number of arguments for function / method. max-args=100 # Maximum number of attributes for a class (see R0902). max-attributes=100 # Maximum number of boolean expressions in an if statement (see R0916). max-bool-expr=10 # Maximum number of branch for function / method body. max-branches=100 # Maximum number of locals for function / method body. max-locals=100 # Maximum number of parents for a class (see R0901). max-parents=10 # Maximum number of public methods for a class (see R0904). max-public-methods=100 # Maximum number of return / yield for function / method body. max-returns=100 # Maximum number of statements in function / method body. max-statements=1000 # Minimum number of public methods for a class (see R0903). min-public-methods=0 [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to # "BaseException, Exception". overgeneral-exceptions=BaseException, Exception python-x3dh-1.0.3/pyproject.toml000066400000000000000000000001211433245423400166120ustar00rootroot00000000000000[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" python-x3dh-1.0.3/requirements.txt000066400000000000000000000001161433245423400171660ustar00rootroot00000000000000XEdDSA>=1.0.0,<2 cryptography>=3.3.2 pydantic>=1.7.4 typing-extensions>=4.3.0 python-x3dh-1.0.3/setup.py000066400000000000000000000040711433245423400154200ustar00rootroot00000000000000# pylint: disable=exec-used import os from typing import Dict, Union, List from setuptools import setup, find_packages # type: ignore[import] source_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), "x3dh") version_scope: Dict[str, Dict[str, str]] = {} with open(os.path.join(source_root, "version.py"), encoding="utf-8") as f: exec(f.read(), version_scope) version = version_scope["__version__"] project_scope: Dict[str, Dict[str, Union[str, List[str]]]] = {} with open(os.path.join(source_root, "project.py"), encoding="utf-8") as f: exec(f.read(), project_scope) project = project_scope["project"] with open("README.md", encoding="utf-8") as f: long_description = f.read() classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy" ] classifiers.extend(project["categories"]) if version["tag"] == "alpha": classifiers.append("Development Status :: 3 - Alpha") if version["tag"] == "beta": classifiers.append("Development Status :: 4 - Beta") if version["tag"] == "stable": classifiers.append("Development Status :: 5 - Production/Stable") del project["categories"] del project["year"] setup( version=version["short"], long_description=long_description, long_description_content_type="text/markdown", license="MIT", packages=find_packages(exclude=["tests"]), install_requires=[ "XEdDSA>=1.0.0,<2", "cryptography>=3.3.2", "pydantic>=1.7.4", "typing-extensions>=4.3.0" ], python_requires=">=3.7", include_package_data=True, zip_safe=False, classifiers=classifiers, **project ) python-x3dh-1.0.3/tests/000077500000000000000000000000001433245423400150465ustar00rootroot00000000000000python-x3dh-1.0.3/tests/__init__.py000066400000000000000000000000401433245423400171510ustar00rootroot00000000000000# To make relative imports work python-x3dh-1.0.3/tests/migration_data/000077500000000000000000000000001433245423400200305ustar00rootroot00000000000000python-x3dh-1.0.3/tests/migration_data/shared-secret-pre-stable.json000066400000000000000000000006551433245423400255160ustar00rootroot00000000000000{"to_other": {"ik": "q36WfYhEbfPBlSLjATVvyUgLhmOzKsK30WRJQqmPllo=", "ek": "P8e+JapT/kxLGPsdLfv0ZxR/I29nNzvBC5dhH2wJuiA=", "otpk": "G0zzGeHaZ0URWEpvgOGuOSOKyxTQJ/+vNjXdLowTIVk=", "spk": "rPFiK18xLb9Qt3z8PO5i8mHdG5bpXipxz1qC4VaODnU="}, "ad": "Q3VydmUyNTUxOUKrfpZ9iERt88GVIuMBNW/JSAuGY7MqwrfRZElCqY+WWhM3TW9udEN1cnZlMjU1MTlCQZDNTFGAfVS/c2T551G1pJVI2lfzG/Lm6ybnWBBvmx4TN01vbnQ=", "sk": "S4LP8nTWJpCiEOCdvE1I/kO3PmFag3Etgc3UXer7iqQ="}python-x3dh-1.0.3/tests/migration_data/state-alice-pre-stable.json000066400000000000000000000344321433245423400251600ustar00rootroot00000000000000{ "changed": false, "ik": { "super": null, "priv": "COW/g93pqWP0dsS8HcF+LNdsIYw/jRSw4dWyQGS9knY=", "pub": "q36WfYhEbfPBlSLjATVvyUgLhmOzKsK30WRJQqmPllo=" }, "spk": { "key": { "super": null, "priv": "QMmzJcYGV2+5Oyp+mKowxxPALFrVxwQ72ioprII5YF0=", "pub": "HKiuQbSexwqBsOKLxha9cauFwdP4TbU32e8+ZSz6G3Q=" }, "signature": "lzZNC9fjSTY+DqHGAKZU7O/OAXb5oG3AAwN4DoXuYxEErD6JgVSiGwwuGHXd12dfaDIxLAoStCW3ZrJc9+/jBA==", "timestamp": 1585394058.4946976 }, "otpks": [ { "super": null, "priv": "wLT6HA7O0EAEU4xeOZWTRWKh7BbMEmRtY3IxoLf6f18=", "pub": "sQlp0vbi/8nJ9P97fE4aZpiQiMOu6JFstd4VbRESPls=" }, { "super": null, "priv": "WJ5Z1qmrsTGOy6nxaIptGHSfZmTT1UjiipesCpzYxVk=", "pub": "MJ1y1tPY8nJoTutl9UwMUdlEnNK4dJexB0Fd0N2CpVI=" }, { "super": null, "priv": "qO7tYrc9+NUoKOvzjH79i0+lariRk1bqEneRuBGdh2U=", "pub": "jEa+FT4AoqWkh2RfIY06NYWHcgZU3lQwU1MmPZ9kKAM=" }, { "super": null, "priv": "yGNOWF0X+EgCyZhax9AtG1a2iO6vwhF+jwceclmgL1M=", "pub": "q3tJFwkNlM51syo5Atp+JrQ+KVmlezkeIpxkRRo+yFU=" }, { "super": null, "priv": "UPjGG3Iw3RYQ5WRXVeGzrbTPMgZEZ47Ou3EeAC2B/GU=", "pub": "wJ9kxrUwS68Ogweg3LCzNFXX6OJWQoWKklYxGAGXW1k=" }, { "super": null, "priv": "qNoQLnQN27nbtBc7toCFUDHSo1cAhbsIIGnJVyrazHA=", "pub": "4nz0N6yyyT1ia2/Y9U9MBCwh95UU5BxRn76EUEdX8Bs=" }, { "super": null, "priv": "QIXSZ/seoykUvIq4dIE0JdnlBf7ZHZW6/TICxVQQkH8=", "pub": "e2CHEu+lLVkbWbr1ghDemuySWNkyvS9uPIxKLk0ccmw=" }, { "super": null, "priv": "uC/iC+q2n0wbtBpjmhrHV5wqMxfZ8QmkfPucnxBAg2Q=", "pub": "cK0Umloz6b7xVglTPjc6prlFqckc9RWwG7cXQuZ2OXY=" }, { "super": null, "priv": "AL1AcY2whrqABXneLJ60WsbHQTFZOVLXphVRuCrdxFM=", "pub": "5ub1i6FtF+H9opeC7xNUVfq7DehzJv0/jT2ZJNUkTSA=" }, { "super": null, "priv": "kItPMETM4pIdhT8ghfpX8CqefW4O+5aE5uSE62f9r2E=", "pub": "UKHUHfiaZv5hRGhzyOeOFdvdm3aRemx67MxCIqHVr3I=" }, { "super": null, "priv": "GD9SNN56Ws8iLozvgwXEtg2X5TddEyFY7oj/Hm9VaEo=", "pub": "ItIweGNnXE0n2MB6T8JY/St/OKFVI7FtEC8ljBIOlhY=" }, { "super": null, "priv": "SEmOEb0SB5TumarbCJmW2A4CJnyT8fdocLDXFJ5uIl4=", "pub": "1LJVkuPmBLERl6Y829ZqZuv5Qf4/PgKFFyE8V9SrsBs=" }, { "super": null, "priv": "WJAqWd4puKS/y2lKG37dhHLJRN6snKkoAo6HrW91xH0=", "pub": "lVTakwmcRCIu02Lb+OdyVUufkWlij1/V5aSPcS9VOA0=" }, { "super": null, "priv": "GJ1/4uf56uwR6BR4qymcGBL5ATLiQVI7cz1xn+084HE=", "pub": "mw2lIlm7/OhO3Qrqvt2J6kqeCyW0+N1DhEN3/o/s/Fg=" }, { "super": null, "priv": "GL9hHChWu4/c5UwlLnKEZp/ivFl92JidkFIuA9PbIkA=", "pub": "i8Yo+cyBK7OgLJ8B9kw9tIIaiPvmvjxpfWg+x8ckogI=" }, { "super": null, "priv": "wIwPkNolAV6zK3HSmdRR9vPz11NDDmi3Qkl8v5gguXc=", "pub": "YvQcftEI4DsVt0nNBLO+SLQMksYy8/k+hYoGp5TJ8jg=" }, { "super": null, "priv": "sC4hgdZ2VO3tZ6iPCQTjOtmFeTHixT0FvDuzx9YIins=", "pub": "Ow3p34cyKTJJRxPUQ7CGpXTi+8F/X9LX/jHKFb6zqSE=" }, { "super": null, "priv": "sPGlbyW5XrqqDP9E33QIq6OR4umtoKEVTVXG4iaLSXo=", "pub": "jbQEr3I78UKhXQzcPBaccELfYy8b3C9z4Z7MBOSMjEE=" }, { "super": null, "priv": "ECV1z2gTjFWGYYRRzTQkJE/L1sfuCk9G8+13VxmoyXA=", "pub": "7d2wcoytWBcCn/SfJZhZ0uoFcsa/vtowpGdhMW4guXI=" }, { "super": null, "priv": "iJQoi4NiIWbOWjVrL/36IiFq1UOletpK6FSZkf3p5UE=", "pub": "thlA8JZfB3V1rjxOEy9Wuk/MJp82tfeyhr0t3SYpfWQ=" }, { "super": null, "priv": "iEP1V/vqYqHbJ1ls4Z2Ttf9pXYl6TzSvRLKPQqOznHU=", "pub": "xR+c6UBMjdN2fbCQJbu0xql0KpF61tkzzSsMr/8d+Sk=" }, { "super": null, "priv": "wDEqzV4Pv0CygeD85VVje2GmwngLJ7jiGPGrHB7ka3A=", "pub": "b6aXeXUd57Yki2IzYZN2dhA6F6758H5Hy2zaOgbkVDI=" }, { "super": null, "priv": "QO0oXuQ+TMdCC0WJ8ypATAGI/yV3JpTFjtlgOr56oGk=", "pub": "PfB7N/RhYta4fQiGL1B2tCHxRKhHWR3DYa79+D/I5Ro=" }, { "super": null, "priv": "2IJCvkTiGuTfOOuyeoUSQsq68Qrkhzp2CT+BrfRXalE=", "pub": "dewaSXUmaKOMJQQM7Frns92xF3mzZkjNEFz86zTBTVE=" }, { "super": null, "priv": "cP7NhzyKOOyHU3hyxhFzeB0x7ySMpaoDwpyUdJ+IymY=", "pub": "KA57jqjwMYukDlzIe7r/pbYMUIWJXd+FiVji5ykQeUM=" }, { "super": null, "priv": "CHyMRAn3yRXhykpG86CRq5CVNpN9n1NaFBXepANCnH8=", "pub": "fH7LUMxfvNNUfBDTK+kS5jQ2vLooWHvSujqWbNMExCs=" }, { "super": null, "priv": "QL6GDsqX1+sG+ZrMzTQzQqy5hGRitkgbV4Y8q2iJvkw=", "pub": "EAk/7MToVcpxUduG1ZPKZY46P0Pu3oQy6ylQJ1G/SEk=" }, { "super": null, "priv": "MBzMtSrYjtMfDVO1UukUfMChO5EnheASB9/NGMvH6mU=", "pub": "ycgAl+TobnBJpygwBt5bxQeORIqHE+WAe0GbRsmtcBY=" }, { "super": null, "priv": "2DwI8U4G1VkZWTt5t5Oxh+BPiMrQP/Xz6u99NWqW1kc=", "pub": "+E08DTDaY+OkpxxfKB8nMfbLp0zMMBFoAzd/Yiq7bE8=" }, { "super": null, "priv": "0HPbzVXhAjX4+jSiYYinItcNk/vscUbJFXoRJUwStU4=", "pub": "Bi4nuYkgg+qIcAgSxAZlqjFNo8OE1BP67lqrUrX+HkM=" }, { "super": null, "priv": "kIqjv+UYAaWARubzxjzvtJRLSY+joyCCqQUnxiHcilU=", "pub": "PTqRXBSjt5Qj421qFRrjpO3KYiZBFBVK1unTb3J2Dhw=" }, { "super": null, "priv": "iLnElRpWuf+M0Z7aKAIQTA8EoAVHV0OJo4CvAVQq62k=", "pub": "Q4iUotEPBje3jXSl/uMeMm93j5PJxqLberWnFI4GgU8=" }, { "super": null, "priv": "mDZ4QdXZtqwYbE2lRZxGc4zJlYf6yMJ0dwOtJKwGG2w=", "pub": "kzDrCaY6ipxhycWTQm/7OMsnDsJlecuAnJ8dkyllX1Q=" }, { "super": null, "priv": "8GywpwCJb5whw1WAJh8/NgDdmD5o5Q9iYfRwr5bn2Vw=", "pub": "7Yld83m8a9p/5tV20IOO5+Mz0Zd0Cm43oo4HWxRUbDo=" }, { "super": null, "priv": "0Hh80bZ9F5beR2PWN8D3akBvOiF/M824u4waQedWB20=", "pub": "a1QuOTPryGWWHaeYS69n1g1+/M8jWDs8n61ev/SUXUU=" }, { "super": null, "priv": "WHJyiCVx/KN8ohHO+KbN6Az67S7LniTY7Ko1ILGKiWE=", "pub": "xBmyZuxJ8OxlPvkH3cMSOYm50qx50lFq8oURqJdxxjA=" }, { "super": null, "priv": "GH4Q64p7s7+pWIdmmwur4UzvqfCkT9ugIuxMKd6AHXk=", "pub": "/efvJ2k0wBDzeW8WOfXzd90H66hoZyUgbAeXENDeUiU=" }, { "super": null, "priv": "QLGQ9Da9L7HnHfOtf7IUrhxZbPIqe/u57pCFTamOvXY=", "pub": "hOkNCJzK0oDmEHGTdCC8rjL6QDFCNR8V/BoDrxOFNnw=" }, { "super": null, "priv": "oIAXIsS4M2BEihbcO6JZhY5pVoCvgihoVmFtcKI562w=", "pub": "3vc5o5500cHLKJCzWu1TZ/I5CNYWPpjKgkQQSsFZsgo=" }, { "super": null, "priv": "KAhBSXRqJcpDjjcsm8cnYYtbBib6vS6IapgF9jH4cUU=", "pub": "m87HPlbXy+2iJAlQbp5dcbmujmaaO/k5RmFp8yNg7B8=" }, { "super": null, "priv": "kPuJsVcTErd7dEbWqNFEjcQi2k9eHmqrT5L+qjQO43Q=", "pub": "RBHMqSNp16ExI1fgGUCZj1oaalLn2xJyZzSNqWrVlxM=" }, { "super": null, "priv": "IBfcTjB5sGAYJ21N5xq18U0n+jFchuOaF9ejqya0pkY=", "pub": "HZcfd+SN/jssj06731jplSXTHNpFo0y9V8Xol3Vu9zs=" }, { "super": null, "priv": "6B0G5vHH3TsLQT/UZ287wD/4y8hJbc5HaqmKb7KkAU4=", "pub": "jVwByTg+JZenbHSMQKJltbIRRyeVMb4dwMjZX0+pRUo=" }, { "super": null, "priv": "GN0N5i5l5lc0o+UkrpXEfhRw5xWs9bFaGxPKh0DvWmk=", "pub": "/NjckKJ7OTMoz0i2SnuRTPz/V5vOJnpJAX7GEr2SVQg=" }, { "super": null, "priv": "UAkjVI2SQl3pzFiOGNrJKnI2Fe2a1JUrtwvBYuBf2k8=", "pub": "vN9hay3uV/gy705xdH1OoC6QBlgmPRMTJKziW3XyVnc=" }, { "super": null, "priv": "yALeQ9YA046l315K/lY+7537GfImwWo+sIdsErMjtk8=", "pub": "Xu70GzuXq+iFQeGmv9ZxemTYJ84nxVHxDoWeZO1Y8yU=" }, { "super": null, "priv": "EMIIXgyDpAXd0/xso6Y2I+e2VM2OKUs8hjFL3izYMG0=", "pub": "a+ttEdfNSdrh49rziKpKlXUa/DVHDbYkW2XyrNzTE1Q=" }, { "super": null, "priv": "iDmOVjL8tg0cE1bcO+T+MXSHcxvMsBbqqwyhkmihHlw=", "pub": "hG4TYHDxudD+MNzj2YIGQN6VygWmPzcDTgPi2YuLUTo=" }, { "super": null, "priv": "iN1c7kDVvbPk/S9ZkMR3BBdPxRXaqeHxpb8awYsL6XM=", "pub": "ur2zl+oMH8v++3Z03H9yT9uCXN+5RYSlyVBE2c9tNF0=" }, { "super": null, "priv": "6DTDOFbPkfXfuFZNa0gX3Fw8lQ/RF/vwedt5NUZkTF8=", "pub": "mbd6dRHtsrhVelXtEZIYfxq/L0beqC2clOZFmNWqFB4=" }, { "super": null, "priv": "eMwxgDNMUWLW4BLbBATU0FpBNibqB8i30m9NdJn2JmI=", "pub": "aRxA+LQaY2lfZh4g00lMtJLf8vZXgaL67fS/GRbFhXQ=" }, { "super": null, "priv": "qEPRzSZuHPpK+HKBR3F2vodxLOOTNCKT7xiMWEV/zng=", "pub": "GEl9rmEw+2vP2e+H1rpt0/e1m5vhNAC1XIcoVlXXDm4=" }, { "super": null, "priv": "CKbsLCL56gRHCUQt8Kdw0z4/7j9pS3833VKCVl1r+2c=", "pub": "AKTPtQgTod9YmglYtOauDkhXzVNJ+8EhcPypH56D614=" }, { "super": null, "priv": "KNq6QUo58pYpt3OxaS3M37tAy1NOniAWJjBgUyHLxno=", "pub": "1qX7VkUjSWuWwilF5mEwHccK7Rg3DdSY8YxBixNdGxU=" }, { "super": null, "priv": "2AiOdVDPnxDsI980QSYn4uFeNeWYomk5pslJ6eDSlX0=", "pub": "bwhyraVbliB/oYYXWlNSHYukeplNg0P+QkLVQ210l0w=" }, { "super": null, "priv": "aLgFfRmA1ksgt1o40o9AZzYvY4TSuEWvlVIUWARQMmU=", "pub": "dJK0Q0mbnnqKBf6qI+jRSNfwXLY+8NEFUOpsP1aY+B0=" }, { "super": null, "priv": "YOmj4TJZjRTPVtwoyEZyPmdCm8h3qFPLB6JVMIsPYn8=", "pub": "hFiT55IVAkuU4l1+OuOHAG0gpnIttFObNcS02dKAszs=" }, { "super": null, "priv": "UEIkDiClwOGI1nV5rva+UBIJ6vQkLmrH07hpBd5+Lns=", "pub": "2c2lyd9TyjBiugHiQHDXgl3zYDl1BaEVVudvMFFNRnY=" }, { "super": null, "priv": "ICF2tvbk9EXh/nIrN4L0SKhK8SpClkKY1EpBECwEF3g=", "pub": "O4HTQ2DtV6//pggghfZYGrU9JZMVUWB3MkFIk6za+jk=" }, { "super": null, "priv": "OHB8dLqZjDimShxk7YHdPa+FhJErtLdMsdOZtKEgMnU=", "pub": "fExCaufIbapm1NhBLWzHF/iutRhgT4Te6T2wW6PYKCA=" }, { "super": null, "priv": "qLUY3NTlcI5qDYGtE6wCrWs4zsYs53rHX3VvMBoZa1s=", "pub": "iDn3utdcZGenUC6WttzMnge8z4Ubhyw8IX8fAH32PWA=" }, { "super": null, "priv": "IGUsVH4RXJQzmhPyBovEz6pI9+3y2uQNZCPBj3E5t1Y=", "pub": "5GlGanM2DmjqQqSKf96lY6vlz6iuqKLgi+XWoACY3kI=" }, { "super": null, "priv": "6MDqbpBmuD/1qQarLGZfMCfYn2/btPrAWPRjcoLbcVQ=", "pub": "dNQGxrjSX8yZSeaAWLOq6izMxbndacClsCVHxRSQK04=" }, { "super": null, "priv": "4GDa2lj1uAieaPYnbfTfohIz9DuVxDMhYdXl/BiQvUc=", "pub": "iljmhBbrQOqnmAJUrbkX+XfZhWAdMqLpxelgRdSaLDw=" }, { "super": null, "priv": "8CpQvtUED2rV5l+727KSo1oIy3skrkgtaAt6cSy5/2g=", "pub": "apsFrkipLGK/WkSeDvGEWl/w37V2/LQNsBP9GgNSo14=" }, { "super": null, "priv": "KHyv9ibgNJqvLj5jCqo1H+8zuYhJaGnAk3XA87vZaks=", "pub": "DWgbhBTdYO7/5PcGzJ4TYF4rsYugwsKshTJULEN+jQY=" }, { "super": null, "priv": "uA68H83QW2ti5bXo7cQbuFJ9orrMkXUzpdK6Fv05+kM=", "pub": "KoSZQ8FM3FBoZ55YpEtSF3IvoJTQTNgYSVaqj3aCKWw=" }, { "super": null, "priv": "yOnEKN7bcwRrk/roH7k0OHonGYaUsA2TJoT5b+0YH0c=", "pub": "Gi/8r072I8+dfHaMipqie9Xq7PwZBiugzn8RepEksgk=" }, { "super": null, "priv": "gJil3gL0LBeaesq1aDa1rkTDfnATigVAqIm740mzmnw=", "pub": "Ufi0dc2YMLQh7lTWo0jTtl4kR4ZbCDsFhfuPFTJV2kQ=" }, { "super": null, "priv": "0O68EqUocIPI3bsBNhQnypBlm3vFGjq4RNOxLjjTVlw=", "pub": "2NPMLDU4vrFcFpaNUXIj3f0X/Bhz9HMan3WaPDUEaWc=" }, { "super": null, "priv": "gAjV4eGeAt+Zt5XAVvLEreoaF+cDWGpFVtHSop0T23I=", "pub": "m2cBczvCOIFucdHVPp5uQCUVH2VmdNkIk/3BjjHNaHk=" } ], "hidden_otpks": [ { "super": null, "priv": "IIL9xrH5TjTXfnthpTM6mfWt6ZmP3r3mO8jc2BTf4EE=", "pub": "ea1Wf2VjSuE2lOf5M9H9gpVNs5kcQCepmq3yCxyy9D4=" }, { "super": null, "priv": "iOrTCzX0HV2j5GwthwHMQi8AEM/ccfxCLkxPnQx4cGs=", "pub": "Gm8o/+mEhRTwo6SE4hsNyan8kNrQu8RRH4qNdpyfi1s=" }, { "super": null, "priv": "MPxv4CIdqFc3yhBvbjyePzgkvd9Js9ztDtv6H7HhNXU=", "pub": "+bjR2JUBtNaoglUzSKE4WS4ZXDPiSOYidlRoDGD2LlM=" }, { "super": null, "priv": "GFtMkbHY5MntJDRDJzSivNhedLU+lvzQMdmGPE+aUlA=", "pub": "DowEKSPIqWcFIic+KYyYV3CEpYxxw+NlbKl3azUJHHo=" }, { "super": null, "priv": "UEdDteqr43RAMyXdLB5AOJt3tP/yfTxNrp0oJp/610c=", "pub": "OTKV7uqlbpDdF7sSDB5JcVEZrniH64nQgPuUwscAOgk=" }, { "super": null, "priv": "8PGBzN+KbOcyRGXQb3bX1afZVSVARY85hjCG7aiYLnQ=", "pub": "Vkx+jZggXmXFMZOj2+f3QMF4Kh+5eYdpQUCMAII9gCQ=" } ] } python-x3dh-1.0.3/tests/migration_data/state-bob-pre-stable.json000066400000000000000000000317201433245423400246420ustar00rootroot00000000000000{"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": []}python-x3dh-1.0.3/tests/test_x3dh.py000066400000000000000000001057761433245423400173450ustar00rootroot00000000000000import base64 import json import os import random import time from typing import Any, Dict, Iterator, List, Optional, Type, Union from unittest import mock import x3dh __all__ = [ # pylint: disable=unused-variable "test_configuration", "test_key_agreements", "test_migrations", "test_old_signed_pre_key", "test_pre_key_availability", "test_pre_key_refill", "test_serialization", "test_signed_pre_key_rotation", "test_signed_pre_key_signature_verification" ] try: import pytest except ImportError: pass else: pytestmark = pytest.mark.asyncio # pylint: disable=unused-variable def flip_random_bit(data: bytes, exclude_msb: bool = False) -> bytes: """ Flip a random bit in a byte array. Args: data: The byte array to flip a random bit in. exclude_msb: Whether the most significant bit of the byte array should be excluded from the random selection. See note below. For Curve25519, the most significant bit of the public key is always cleared/ignored, as per RFC 7748 (on page 7). Thus, a bit flip of that bit does not make the signature verification fail, because the bit flip is ignored. The `exclude_msb` parameter can be used to disallow the bit flip to appear on the most significant bit and should be set when working with Curve25519 public keys. Returns: The data with a random bit flipped. """ while True: modify_byte = random.randrange(len(data)) modify_bit = random.randrange(8) # If the most significant bit was randomly chosen and `exclude_msb` is set, choose again. if not (exclude_msb and modify_byte == len(data) - 1 and modify_bit == 7): break data_mut = bytearray(data) data_mut[modify_byte] ^= 1 << modify_bit return bytes(data_mut) bundles: Dict[bytes, x3dh.Bundle] = {} class ExampleState(x3dh.State): """ A state implementation for testing, which simulates bundle uploads by storing them in a global variable, and does some fancy public key encoding. """ def _publish_bundle(self, bundle: x3dh.Bundle) -> None: bundles[bundle.identity_key] = bundle @staticmethod def _encode_public_key(key_format: x3dh.IdentityKeyFormat, pub: bytes) -> bytes: return b"\x42" + pub + b"\x13\x37" + key_format.value.encode("ASCII") def get_bundle(state: ExampleState) -> x3dh.Bundle: """ Retrieve a bundle from the simulated server. Args: state: The state to retrieve the bundle for. Returns: The bundle. Raises: AssertionError: if the bundle was never "uploaded". """ if state.bundle.identity_key in bundles: return bundles[state.bundle.identity_key] assert False def create_state(state_settings: Dict[str, Any]) -> ExampleState: """ Create an :class:`ExampleState` and make sure the state creation worked as expected. Args: state_settings: Arguments to pass to :meth:`ExampleState.create`. Returns: The state. Raises: AssertionError: in case of failure. """ exc: Optional[BaseException] = None state: Optional[ExampleState] = None try: state = ExampleState.create(**state_settings) except BaseException as e: # pylint: disable=broad-except exc = e assert exc is None assert state is not None get_bundle(state) return state def create_state_expect( state_settings: Dict[str, Any], expected_exception: Type[BaseException], expected_message: Union[str, List[str]] ) -> None: """ Create an :class:`ExampleState`, but expect an exception to be raised during creation.. Args: state_settings: Arguments to pass to :meth:`ExampleState.create`. expected_exception: The exception type expected to be raised. expected_message: The message expected to be raised, or a list of message snippets that should be part of the exception message. Raises: AssertionError: in case of failure. """ exc: Optional[BaseException] = None state: Optional[ExampleState] = None try: state = ExampleState.create(**state_settings) except BaseException as e: # pylint: disable=broad-except exc = e assert state is None assert isinstance(exc, expected_exception) if not isinstance(expected_message, list): expected_message = [ expected_message ] for expected_message_snippet in expected_message: assert expected_message_snippet in str(exc) def generate_settings( info: bytes, signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60, pre_key_refill_threshold: int = 25, pre_key_refill_target: int = 100 ) -> Iterator[Dict[str, Any]]: """ Generate state creation arguments. Args: info: The info to use constantly. signed_pre_key_rotation_period: The signed pre key rotation period to use constantly. pre_key_refill_threshold: The pre key refill threshold to use constantly. pre_key_refill_target. The pre key refill target to use constantly. Returns: An iterator which yields a set of state creation arguments, returning all valid combinations of identity key format and hash function with the given constant values. """ for identity_key_format in [ x3dh.IdentityKeyFormat.CURVE_25519, x3dh.IdentityKeyFormat.ED_25519 ]: for hash_function in [ x3dh.HashFunction.SHA_256, x3dh.HashFunction.SHA_512 ]: state_settings: Dict[str, Any] = { "identity_key_format": identity_key_format, "hash_function": hash_function, "info": info, "signed_pre_key_rotation_period": signed_pre_key_rotation_period, "pre_key_refill_threshold": pre_key_refill_threshold, "pre_key_refill_target": pre_key_refill_target } yield state_settings async def test_key_agreements() -> None: """ Test the general key agreement functionality. """ for state_settings in generate_settings("test_key_agreements".encode("ASCII")): state_a = create_state(state_settings) state_b = create_state(state_settings) # Store the current bundles bundle_a_before = get_bundle(state_a) bundle_b_before = get_bundle(state_b) # Perform the first, active half of the key agreement shared_secret_active, associated_data_active, header = await state_a.get_shared_secret_active( bundle_b_before, "ad appendix".encode("ASCII") ) # Perform the second, passive half of the key agreement shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive( header, "ad appendix".encode("ASCII") ) # Store the current bundles bundle_a_after = get_bundle(state_a) bundle_b_after = get_bundle(state_b) # The bundle of the active party should remain unmodified: assert bundle_a_after == bundle_a_before # The bundle of the passive party should have been modified and published again: assert bundle_b_after != bundle_b_before # To be exact, only one pre key should have been removed from the bundle: assert bundle_b_after.identity_key == bundle_b_before.identity_key assert bundle_b_after.signed_pre_key == bundle_b_before.signed_pre_key assert bundle_b_after.signed_pre_key_sig == bundle_b_before.signed_pre_key_sig assert len(bundle_b_after.pre_keys) == len(bundle_b_before.pre_keys) - 1 assert all(pre_key in bundle_b_before.pre_keys for pre_key in bundle_b_after.pre_keys) # Both parties should have derived the same shared secret and built the same # associated data: assert shared_secret_active == shared_secret_passive assert associated_data_active == associated_data_passive # It should not be possible to accept the same header again: try: await state_b.get_shared_secret_passive( header, "ad appendix".encode("ASCII") ) assert False except x3dh.KeyAgreementException as e: assert "pre key" in str(e) assert "not available" in str(e) # If the key agreement does not use a pre key, it should be possible to accept the header # multiple times: bundle_b = get_bundle(state_b) bundle_b = x3dh.Bundle( identity_key=bundle_b.identity_key, signed_pre_key=bundle_b.signed_pre_key, signed_pre_key_sig=bundle_b.signed_pre_key_sig, pre_keys=frozenset() ) shared_secret_active, associated_data_active, header = await state_a.get_shared_secret_active( bundle_b, require_pre_key=False ) shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive( header, require_pre_key=False ) assert shared_secret_active == shared_secret_passive assert associated_data_active == associated_data_passive shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive( header, require_pre_key=False ) assert shared_secret_active == shared_secret_passive assert associated_data_active == associated_data_passive def test_configuration() -> None: """ Test whether incorrect argument values are rejected correctly. """ for state_settings in generate_settings("test_configuration".encode("ASCII")): # Before destorying the settings, make sure that the state could be created like that: create_state(state_settings) state_settings["info"] = "test_configuration".encode("ASCII") # Pass an invalid timeout for the signed pre key state_settings["signed_pre_key_rotation_period"] = 0 create_state_expect(state_settings, ValueError, "signed_pre_key_rotation_period") state_settings["signed_pre_key_rotation_period"] = -random.randrange(1, 2**64) create_state_expect(state_settings, ValueError, "signed_pre_key_rotation_period") state_settings["signed_pre_key_rotation_period"] = 1 # Pass an invalid combination of pre_key_refill_threshold and pre_key_refill_target # pre_key_refill_threshold too small state_settings["pre_key_refill_threshold"] = 0 create_state_expect(state_settings, ValueError, "pre_key_refill_threshold") state_settings["pre_key_refill_threshold"] = 25 # pre_key_refill_target too small state_settings["pre_key_refill_target"] = 0 create_state_expect(state_settings, ValueError, "pre_key_refill_target") state_settings["pre_key_refill_target"] = 100 # pre_key_refill_threshold above pre_key_refill_target state_settings["pre_key_refill_threshold"] = 100 state_settings["pre_key_refill_target"] = 25 create_state_expect(state_settings, ValueError, [ "pre_key_refill_threshold", "pre_key_refill_target" ]) state_settings["pre_key_refill_threshold"] = 25 state_settings["pre_key_refill_target"] = 100 # pre_key_refill_threshold equals pre_key_refill_target (this should succeed) state_settings["pre_key_refill_threshold"] = 25 state_settings["pre_key_refill_target"] = 25 create_state(state_settings) state_settings["pre_key_refill_threshold"] = 25 state_settings["pre_key_refill_target"] = 100 async def test_pre_key_refill() -> None: """ Test pre key refill. """ for state_settings in generate_settings( "test_pre_key_refill".encode("ASCII"), pre_key_refill_threshold=5, pre_key_refill_target=10 ): state_a = create_state(state_settings) state_b = create_state(state_settings) # Verify that the bundle contains 100 pre keys initially: prev = len(get_bundle(state_b).pre_keys) assert prev == state_settings["pre_key_refill_target"] # Perform a lot of key agreements and verify that the refill works as expected: for _ in range(100): header = (await state_a.get_shared_secret_active(get_bundle(state_b)))[2] await state_b.get_shared_secret_passive(header) num_pre_keys = len(get_bundle(state_b).pre_keys) if prev == state_settings["pre_key_refill_threshold"]: assert num_pre_keys == state_settings["pre_key_refill_target"] else: assert num_pre_keys == prev - 1 prev = num_pre_keys async def test_signed_pre_key_signature_verification() -> None: """ Test signature verification of the signed pre key. """ for state_settings in generate_settings("test_signed_pre_key_signature_verification".encode("ASCII")): identity_key_format: x3dh.IdentityKeyFormat = state_settings["identity_key_format"] for _ in range(8): state_a = create_state(state_settings) state_b = create_state(state_settings) bundle = get_bundle(state_b) # First, make sure that the active half of the key agreement usually works: await state_a.get_shared_secret_active(bundle) # Now, flip a random bit in # 1. the signature # 2. the identity key # 3. the signed pre key # and make sure that the active half of the key agreement reject the signature. # 1.: the signature signed_pre_key_sig = flip_random_bit(bundle.signed_pre_key_sig) bundle_modified = x3dh.Bundle( identity_key=bundle.identity_key, signed_pre_key=bundle.signed_pre_key, signed_pre_key_sig=signed_pre_key_sig, pre_keys=bundle.pre_keys ) try: await state_a.get_shared_secret_active(bundle_modified) assert False except x3dh.KeyAgreementException as e: assert "signature" in str(e) # 2.: the identity key exclude_msb = identity_key_format is x3dh.IdentityKeyFormat.CURVE_25519 identity_key = flip_random_bit(bundle.identity_key, exclude_msb=exclude_msb) bundle_modified = x3dh.Bundle( identity_key=identity_key, signed_pre_key=bundle.signed_pre_key, signed_pre_key_sig=bundle.signed_pre_key_sig, pre_keys=bundle.pre_keys ) try: await state_a.get_shared_secret_active(bundle_modified) assert False except x3dh.KeyAgreementException as e: assert "signature" in str(e) # 3.: the signed pre key signed_pre_key = flip_random_bit(bundle.signed_pre_key) bundle_modified = x3dh.Bundle( identity_key=bundle.identity_key, signed_pre_key=signed_pre_key, signed_pre_key_sig=bundle.signed_pre_key_sig, pre_keys=bundle.pre_keys ) try: await state_a.get_shared_secret_active(bundle_modified) assert False except x3dh.KeyAgreementException as e: assert "signature" in str(e) async def test_pre_key_availability() -> None: """ Test whether key agreements without pre keys work/are rejected as expected. """ for state_settings in generate_settings("test_pre_key_availability".encode("ASCII")): state_a = create_state(state_settings) state_b = create_state(state_settings) # First, test the active half of the key agreement for require_pre_key in [ True, False ]: for include_pre_key in [ True, False ]: bundle = get_bundle(state_b) # Make sure that the bundle contains pre keys: assert len(bundle.pre_keys) > 0 # If required for the test, remove all pre keys: if not include_pre_key: bundle = x3dh.Bundle( identity_key=bundle.identity_key, signed_pre_key=bundle.signed_pre_key, signed_pre_key_sig=bundle.signed_pre_key_sig, pre_keys=frozenset() ) should_fail = require_pre_key and not include_pre_key try: header = (await state_a.get_shared_secret_active( bundle, require_pre_key=require_pre_key ))[2] assert not should_fail assert (header.pre_key is not None) == include_pre_key except x3dh.KeyAgreementException as e: assert should_fail assert "does not contain" in str(e) assert "pre key" in str(e) # Second, test the passive half of the key agreement for require_pre_key in [ True, False ]: for include_pre_key in [ True, False ]: bundle = get_bundle(state_b) # Make sure that the bundle contains pre keys: assert len(bundle.pre_keys) > 0 # If required for the test, remove all pre keys: if not include_pre_key: bundle = x3dh.Bundle( identity_key=bundle.identity_key, signed_pre_key=bundle.signed_pre_key, signed_pre_key_sig=bundle.signed_pre_key_sig, pre_keys=frozenset() ) # Perform the active half of the key agreement, using a pre key only if required for # the test. shared_secret_active, _, header = await state_a.get_shared_secret_active( bundle, require_pre_key=False ) should_fail = require_pre_key and not include_pre_key try: shared_secret_passive, _, _ = await state_b.get_shared_secret_passive( header, require_pre_key=require_pre_key ) assert not should_fail assert shared_secret_passive == shared_secret_active except x3dh.KeyAgreementException as e: assert should_fail assert "does not use" in str(e) assert "pre key" in str(e) THREE_DAYS = 3 * 24 * 60 * 60 EIGHT_DAYS = 8 * 24 * 60 * 60 async def test_signed_pre_key_rotation() -> None: """ Test signed pre key rotation logic. """ for state_settings in generate_settings("test_signed_pre_key_rotation".encode("ASCII")): state_b = create_state(state_settings) bundle_b = get_bundle(state_b) current_time = time.time() time_mock = mock.MagicMock() # Mock time.time, so that the test can skip days in an instant with mock.patch("time.time", time_mock): # ExampleState.create should call time.time only once, when generating the signed pre key. Make # the mock return the actual current time for that call. time_mock.return_value = current_time state_a = create_state(state_settings) assert time_mock.call_count == 1 time_mock.reset_mock() # Prepare a key agreement header, the time is irrelevant here. Don't use a pre key so # that the header can be used multiple times. bundle_a = get_bundle(state_a) bundle_a = x3dh.Bundle( identity_key=bundle_a.identity_key, signed_pre_key=bundle_a.signed_pre_key, signed_pre_key_sig=bundle_a.signed_pre_key_sig, pre_keys=frozenset() ) time_mock.return_value = current_time + THREE_DAYS header_b = (await state_b.get_shared_secret_active(bundle_a, require_pre_key=False))[2] state_b.rotate_signed_pre_key() assert time_mock.call_count == 1 time_mock.reset_mock() # There are three methods that check whether the signed pre key has to be rotated: # 1. get_shared_secret_active # 2. get_shared_secret_passive # 3. deserialize # 1. get_shared_secret_active # Make the mock return the actual current time plus three days. This should not trigger a # rotation. bundle_a_before = get_bundle(state_a) time_mock.return_value = current_time + THREE_DAYS await state_a.get_shared_secret_active(bundle_b) state_a.rotate_signed_pre_key() assert time_mock.call_count == 1 time_mock.reset_mock() assert get_bundle(state_a) == bundle_a_before # Make the mock return the actual current time plus eight days. This should trigger a rotation. # A rotation reads the time twice. bundle_a_before = get_bundle(state_a) time_mock.return_value = current_time + EIGHT_DAYS await state_a.get_shared_secret_active(bundle_b) state_a.rotate_signed_pre_key() assert time_mock.call_count == 2 time_mock.reset_mock() assert get_bundle(state_a).identity_key == bundle_a_before.identity_key assert get_bundle(state_a).signed_pre_key != bundle_a_before.signed_pre_key assert get_bundle(state_a).signed_pre_key_sig != bundle_a_before.signed_pre_key_sig assert get_bundle(state_a).pre_keys == bundle_a_before.pre_keys # Update the "current_time" to the creation time of the last signed pre key: current_time += EIGHT_DAYS # 2. get_shared_secret_passive # Make the mock return the actual current time plus three days. This should not trigger a # rotation. bundle_a_before = get_bundle(state_a) time_mock.return_value = current_time + THREE_DAYS await state_a.get_shared_secret_passive(header_b, require_pre_key=False) state_a.rotate_signed_pre_key() assert time_mock.call_count == 1 time_mock.reset_mock() assert get_bundle(state_a) == bundle_a_before # Make the mock return the actual current time plus eight days. This should trigger a rotation. # A rotation reads the time twice. bundle_a_before = get_bundle(state_a) time_mock.return_value = current_time + EIGHT_DAYS await state_a.get_shared_secret_passive(header_b, require_pre_key=False) state_a.rotate_signed_pre_key() assert time_mock.call_count == 2 time_mock.reset_mock() assert get_bundle(state_a).identity_key == bundle_a_before.identity_key assert get_bundle(state_a).signed_pre_key != bundle_a_before.signed_pre_key assert get_bundle(state_a).signed_pre_key_sig != bundle_a_before.signed_pre_key_sig assert get_bundle(state_a).pre_keys == bundle_a_before.pre_keys # Update the "current_time" to the creation time of the last signed pre key: current_time += EIGHT_DAYS # 3. deserialize # Make the mock return the actual current time plus three days. This should not trigger a # rotation. bundle_a_before = get_bundle(state_a) time_mock.return_value = current_time + THREE_DAYS state_a = ExampleState.from_model(state_a.model, **state_settings) assert time_mock.call_count == 1 time_mock.reset_mock() assert get_bundle(state_a) == bundle_a_before # Make the mock return the actual current time plus eight days. This should trigger a rotation. # A rotation reads the time twice. bundle_a_before = get_bundle(state_a) time_mock.return_value = current_time + EIGHT_DAYS state_a = ExampleState.from_model(state_a.model, **state_settings) assert time_mock.call_count == 2 time_mock.reset_mock() assert get_bundle(state_a).identity_key == bundle_a_before.identity_key assert get_bundle(state_a).signed_pre_key != bundle_a_before.signed_pre_key assert get_bundle(state_a).signed_pre_key_sig != bundle_a_before.signed_pre_key_sig assert get_bundle(state_a).pre_keys == bundle_a_before.pre_keys # Update the "current_time" to the creation time of the last signed pre key: current_time += EIGHT_DAYS async def test_old_signed_pre_key() -> None: """ Test that the old signed pre key remains available for key agreements for one further rotation period. """ for state_settings in generate_settings( "test_old_signed_pre_key".encode("ASCII"), signed_pre_key_rotation_period=2 ): print(state_settings) state_a = create_state(state_settings) state_b = create_state(state_settings) # Prepare a key agreement header using the current signed pre key of state a. Don't use a pre # key so that the header can be used multiple times. bundle_a = get_bundle(state_a) bundle_a_no_pre_keys = x3dh.Bundle( identity_key=bundle_a.identity_key, signed_pre_key=bundle_a.signed_pre_key, signed_pre_key_sig=bundle_a.signed_pre_key_sig, pre_keys=frozenset() ) shared_secret_active, associated_data_active, header = await state_b.get_shared_secret_active( bundle_a_no_pre_keys, require_pre_key=False ) # Make sure that this key agreement works as intended: shared_secret_passive, associated_data_passive, _ = await state_a.get_shared_secret_passive( header, require_pre_key=False ) assert shared_secret_active == shared_secret_passive assert associated_data_active == associated_data_passive # Rotate the signed pre key once. The rotation period is specified as two days, still skipping eight # days should only trigger a single rotation. current_time = time.time() time_mock = mock.MagicMock() # Mock time.time, so that the test can skip days in an instant with mock.patch("time.time", time_mock): time_mock.return_value = current_time + EIGHT_DAYS state_a = ExampleState.from_model(state_a.model, **state_settings) assert time_mock.call_count == 2 time_mock.reset_mock() # Make sure that the signed pre key was rotated: assert get_bundle(state_a).identity_key == bundle_a.identity_key assert get_bundle(state_a).signed_pre_key != bundle_a.signed_pre_key assert get_bundle(state_a).signed_pre_key_sig != bundle_a.signed_pre_key_sig assert get_bundle(state_a).pre_keys == bundle_a.pre_keys bundle_a_rotated = get_bundle(state_a) # The old signed pre key should still be stored in state_a, thus the old key agreement header should # still work: shared_secret_passive, associated_data_passive, _ = await state_a.get_shared_secret_passive( header, require_pre_key=False ) assert shared_secret_active == shared_secret_passive assert associated_data_active == associated_data_passive # Rotate the signed pre key again: with mock.patch("time.time", time_mock): time_mock.return_value = current_time + EIGHT_DAYS + THREE_DAYS state_a = ExampleState.from_model(state_a.model, **state_settings) assert time_mock.call_count == 2 time_mock.reset_mock() # Make sure that the signed pre key was rotated again: assert get_bundle(state_a).identity_key == bundle_a.identity_key assert get_bundle(state_a).signed_pre_key != bundle_a.signed_pre_key assert get_bundle(state_a).signed_pre_key_sig != bundle_a.signed_pre_key_sig assert get_bundle(state_a).pre_keys == bundle_a.pre_keys assert get_bundle(state_a).identity_key == bundle_a_rotated.identity_key assert get_bundle(state_a).signed_pre_key != bundle_a_rotated.signed_pre_key assert get_bundle(state_a).signed_pre_key_sig != bundle_a_rotated.signed_pre_key_sig assert get_bundle(state_a).pre_keys == bundle_a_rotated.pre_keys # Now the signed pre key used in the header should not be available any more, the passive half of the # key agreement should fail: try: await state_a.get_shared_secret_passive(header, require_pre_key=False) assert False except x3dh.KeyAgreementException as e: assert "signed pre key" in str(e) assert "not available" in str(e) async def test_serialization() -> None: """ Test (de)serialization. """ for state_settings in generate_settings("test_serialization".encode("ASCII")): state_a = create_state(state_settings) state_b = create_state(state_settings) # Make sure that the key agreement works normally: shared_secret_active, associated_data_acitve, header = await state_a.get_shared_secret_active( get_bundle(state_b) ) shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) assert shared_secret_active == shared_secret_passive assert associated_data_acitve == associated_data_passive # Do the same thing but serialize and deserialize state b before performing the passive half of the # key agreement: bundle_b_before = get_bundle(state_b) shared_secret_active, associated_data_acitve, header = await state_a.get_shared_secret_active( get_bundle(state_b) ) state_b = ExampleState.from_model(state_b.model, **state_settings) shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) assert shared_secret_active == shared_secret_passive assert associated_data_acitve == associated_data_passive # Make sure that the bundle remained the same, except for one pre key being deleted: assert get_bundle(state_b).identity_key == bundle_b_before.identity_key assert get_bundle(state_b).signed_pre_key == bundle_b_before.signed_pre_key assert get_bundle(state_b).signed_pre_key_sig == bundle_b_before.signed_pre_key_sig assert len(get_bundle(state_b).pre_keys) == len(bundle_b_before.pre_keys) - 1 assert all(pre_key in bundle_b_before.pre_keys for pre_key in get_bundle(state_b).pre_keys) # Accepting a key agreement using a pre key results in the pre key being deleted # from the state. Use (de)serialization to circumvent the deletion of the pre key. This time # also serialize the structure into JSON: shared_secret_active, associated_data_acitve, header = await state_a.get_shared_secret_active( get_bundle(state_b) ) state_b_serialized = json.dumps(state_b.json) # Accepting the header should work once... shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) assert shared_secret_active == shared_secret_passive assert associated_data_acitve == associated_data_passive # ...but fail the second time: try: await state_b.get_shared_secret_passive(header) assert False except x3dh.KeyAgreementException as e: assert "pre key" in str(e) assert "not available" in str(e) # After restoring the state, it should work again: state_b, needs_publish = ExampleState.from_json(json.loads(state_b_serialized), **state_settings) shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) assert not needs_publish assert shared_secret_active == shared_secret_passive assert associated_data_acitve == associated_data_passive THIS_FILE_PATH = os.path.dirname(os.path.abspath(__file__)) async def test_migrations() -> None: """ Test the migration from pre-stable. """ state_settings: Dict[str, Any] = { "identity_key_format": x3dh.IdentityKeyFormat.CURVE_25519, "hash_function": x3dh.HashFunction.SHA_256, "info": "test_migrations".encode("ASCII"), "signed_pre_key_rotation_period": 7, "pre_key_refill_threshold": 25, "pre_key_refill_target": 100 } with open(os.path.join( THIS_FILE_PATH, "migration_data", "state-alice-pre-stable.json" ), "r", encoding="utf-8") as state_alice_pre_stable_json: state_a_serialized = json.load(state_alice_pre_stable_json) with open(os.path.join( THIS_FILE_PATH, "migration_data", "state-bob-pre-stable.json" ), "r", encoding="utf-8") as state_bob_pre_stable_json: state_b_serialized = json.load(state_bob_pre_stable_json) with open(os.path.join( THIS_FILE_PATH, "migration_data", "shared-secret-pre-stable.json" ), "r", encoding="utf-8") as shared_secret_pey_stable_json: shared_secret_active_serialized = json.load(shared_secret_pey_stable_json) # Convert the pre-stable shared secret structure into a x3dh.SharedSecretActive shared_secret_active = base64.b64decode(shared_secret_active_serialized["sk"].encode("ASCII")) associated_data_active = base64.b64decode(shared_secret_active_serialized["ad"].encode("ASCII")) header = x3dh.Header( identity_key=base64.b64decode(shared_secret_active_serialized["to_other"]["ik"].encode("ASCII")), ephemeral_key=base64.b64decode(shared_secret_active_serialized["to_other"]["ek"].encode("ASCII")), signed_pre_key=base64.b64decode(shared_secret_active_serialized["to_other"]["spk"].encode("ASCII")), pre_key=base64.b64decode(shared_secret_active_serialized["to_other"]["otpk"].encode("ASCII")) ) # Load state a. This should not trigger a publishing of the bundle, as the `changed` flag is not set. state_a, _needs_publish = ExampleState.from_json(state_a_serialized, **state_settings) try: get_bundle(state_a) assert False except AssertionError: pass # Load state b. This should trigger a publishing of the bundle, as the `changed` flag is set. state_b, _needs_publish = ExampleState.from_json(state_b_serialized, **state_settings) get_bundle(state_b) # Complete the passive half of the key agreement as created by the pre-stable version: shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) assert shared_secret_active == shared_secret_passive # Don't check the associated data, since formats have changed. # Try another key agreement using the migrated sessions: shared_secret_active, associated_data_active, header = await state_a.get_shared_secret_active( get_bundle(state_b) ) shared_secret_passive, associated_data_passive, _ = await state_b.get_shared_secret_passive(header) assert shared_secret_active == shared_secret_passive assert associated_data_active == associated_data_passive python-x3dh-1.0.3/x3dh/000077500000000000000000000000001433245423400145525ustar00rootroot00000000000000python-x3dh-1.0.3/x3dh/__init__.py000066400000000000000000000015351433245423400166670ustar00rootroot00000000000000from .version import __version__ from .project import project from .base_state import KeyAgreementException, BaseState from .crypto_provider import HashFunction from .models import BaseStateModel, IdentityKeyPairModel, SignedPreKeyPairModel from .state import State from .types import Bundle, Header, IdentityKeyFormat, JSONObject # Fun: # https://github.com/PyCQA/pylint/issues/6006 # https://github.com/python/mypy/issues/10198 __all__ = [ # pylint: disable=unused-variable # .version "__version__", # .project "project", # .base_state "BaseState", "KeyAgreementException", # .crypto_provider "HashFunction", # .models "BaseStateModel", "IdentityKeyPairModel", "SignedPreKeyPairModel", # .state "State", # .types "Bundle", "Header", "IdentityKeyFormat", "JSONObject" ] python-x3dh-1.0.3/x3dh/base_state.py000066400000000000000000000516441433245423400172500ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable from abc import ABC, abstractmethod import json import time import secrets from typing import FrozenSet, Optional, Set, Tuple, Type, TypeVar, cast import xeddsa from .crypto_provider import HashFunction from .crypto_provider_cryptography import CryptoProviderImpl from .identity_key_pair import IdentityKeyPair, IdentityKeyPairSeed from .migrations import parse_base_state_model from .models import BaseStateModel from .pre_key_pair import PreKeyPair from .signed_pre_key_pair import SignedPreKeyPair from .types import Bundle, IdentityKeyFormat, Header, JSONObject __all__ = [ # pylint: disable=unused-variable "KeyAgreementException", "BaseState" ] class KeyAgreementException(Exception): """ Exception raised by :meth:`BaseState.get_shared_secret_active` and :meth:`BaseState.get_shared_secret_passive` in case of an error related to the key agreement operation. """ BaseStateTypeT = TypeVar("BaseStateTypeT", bound="BaseState") class BaseState(ABC): """ This class is the core of this X3DH implementation. It offers methods to manually manage the X3DH state and perform key agreements with other parties. Warning: This class requires manual state management, including e.g. signed pre key rotation, pre key hiding/deletion and refills. The subclass :class:`~x3dh.state.State` automates those management/maintenance tasks and should be preferred if external/manual management is not explicitly wanted. """ def __init__(self) -> None: # Just the type definitions here self.__identity_key_format: IdentityKeyFormat self.__hash_function: HashFunction self.__info: bytes self.__identity_key: IdentityKeyPair self.__signed_pre_key: SignedPreKeyPair self.__old_signed_pre_key: Optional[SignedPreKeyPair] self.__pre_keys: Set[PreKeyPair] self.__hidden_pre_keys: Set[PreKeyPair] @classmethod def create( cls: Type[BaseStateTypeT], identity_key_format: IdentityKeyFormat, hash_function: HashFunction, info: bytes, identity_key_pair: Optional[IdentityKeyPair] = None ) -> BaseStateTypeT: """ Args: identity_key_format: The format in which the identity public key is included in bundles/headers. hash_function: A 256 or 512-bit hash function. info: A (byte) string identifying the application. identity_key_pair: If set, use the given identity key pair instead of generating a new one. Returns: A configured instance of :class:`~x3dh.base_state.BaseState`. Note that an identity key pair and a signed pre key are generated, but no pre keys. Use :meth:`generate_pre_keys` to generate some. """ self = cls() self.__identity_key_format = identity_key_format self.__hash_function = hash_function self.__info = info self.__identity_key = identity_key_pair or IdentityKeyPairSeed(secrets.token_bytes(32)) self.__signed_pre_key = self.__generate_spk() self.__old_signed_pre_key = None self.__pre_keys = set() self.__hidden_pre_keys = set() return self #################### # abstract methods # #################### @staticmethod @abstractmethod def _encode_public_key(key_format: IdentityKeyFormat, pub: bytes) -> bytes: """ Args: key_format: The format in which this public key is serialized. pub: The public key. Returns: An encoding of the public key, possibly including information about the curve and type of key, though this is application defined. Note that two different public keys must never result in the same byte sequence, uniqueness of the public keys must be preserved. """ raise NotImplementedError("Create a subclass of BaseState and implement `_encode_public_key`.") ################# # serialization # ################# @property def model(self) -> BaseStateModel: """ Returns: The internal state of this :class:`BaseState` as a pydantic model. Note that pre keys hidden using :meth:`hide_pre_key` are not considered part of the state. """ return BaseStateModel( identity_key=self.__identity_key.model, signed_pre_key=self.__signed_pre_key.model, old_signed_pre_key=None if self.__old_signed_pre_key is None else self.__old_signed_pre_key.model, pre_keys=frozenset(pre_key.priv for pre_key in self.__pre_keys) ) @property def json(self) -> JSONObject: """ Returns: The internal state of this :class:`BaseState` as a JSON-serializable Python object. Note that pre keys hidden using :meth:`hide_pre_key` are not considered part of the state. """ return cast(JSONObject, json.loads(self.model.json())) @classmethod def from_model( cls: Type[BaseStateTypeT], model: BaseStateModel, identity_key_format: IdentityKeyFormat, hash_function: HashFunction, info: bytes ) -> BaseStateTypeT: """ Args: model: The pydantic model holding the internal state of a :class:`BaseState`, as produced by :attr:`model`. identity_key_format: The format in which the identity public key is included in bundles/headers. hash_function: A 256 or 512-bit hash function. info: A (byte) string identifying the application. Returns: A configured instance of :class:`BaseState`, with internal state restored from the model. Warning: Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the documentation for details. """ self = cls() self.__identity_key_format = identity_key_format self.__hash_function = hash_function self.__info = info self.__identity_key = IdentityKeyPair.from_model(model.identity_key) self.__signed_pre_key = SignedPreKeyPair.from_model(model.signed_pre_key) self.__old_signed_pre_key = ( None if model.old_signed_pre_key is None else SignedPreKeyPair.from_model(model.old_signed_pre_key) ) self.__pre_keys = { PreKeyPair(pre_key) for pre_key in model.pre_keys } self.__hidden_pre_keys = set() return self @classmethod def from_json( cls: Type[BaseStateTypeT], serialized: JSONObject, identity_key_format: IdentityKeyFormat, hash_function: HashFunction, info: bytes ) -> Tuple[BaseStateTypeT, bool]: """ Args: serialized: A JSON-serializable Python object holding the internal state of a :class:`BaseState`, as produced by :attr:`json`. identity_key_format: The format in which the identity public key is included in bundles/headers. hash_function: A 256 or 512-bit hash function. info: A (byte) string identifying the application. Returns: A configured instance of :class:`BaseState`, with internal state restored from the serialized data, and a flag that indicates whether the bundle needs to be published. The latter was part of the pre-stable serialization format. """ model, bundle_needs_publish = parse_base_state_model(serialized) self = cls.from_model( model, identity_key_format, hash_function, info ) return self, bundle_needs_publish ################################# # key generation and management # ################################# def __generate_spk(self) -> SignedPreKeyPair: """ Returns: A newly generated signed pre key. """ # Get the own identity key in the format required for signing, forcing the sign bit if necessary to # comply with XEdDSA identity_key = self.__identity_key.as_priv().priv if self.__identity_key_format is IdentityKeyFormat.CURVE_25519: identity_key = xeddsa.priv_force_sign(identity_key, False) # Generate the private key of the new signed pre key priv = secrets.token_bytes(32) # Sign the encoded public key of the new signed pre key sig = xeddsa.ed25519_priv_sign( identity_key, self._encode_public_key(IdentityKeyFormat.CURVE_25519, xeddsa.priv_to_curve25519_pub(priv)) ) # Add the current timestamp return SignedPreKeyPair(priv=priv, sig=sig, timestamp=int(time.time())) @property def old_signed_pre_key(self) -> Optional[bytes]: """ Returns: The old signed pre key, if there is one. """ return None if self.__old_signed_pre_key is None else self.__old_signed_pre_key.pub def signed_pre_key_age(self) -> int: """ Returns: The age of the signed pre key, i.e. the time elapsed since it was last rotated, in seconds. """ return int(time.time()) - self.__signed_pre_key.timestamp def rotate_signed_pre_key(self) -> None: """ Rotate the signed pre key. Keep the old signed pre key around for one additional rotation period, i.e. until this method is called again. """ self.__old_signed_pre_key = self.__signed_pre_key self.__signed_pre_key = self.__generate_spk() @property def hidden_pre_keys(self) -> FrozenSet[bytes]: """ Returns: The currently hidden pre keys. """ return frozenset(pre_key.pub for pre_key in self.__hidden_pre_keys) def hide_pre_key(self, pre_key_pub: bytes) -> bool: """ Hide a pre key from the bundle returned by :attr:`bundle` and pre key count returned by :meth:`get_num_visible_pre_keys`, but keep the pre key for cryptographic operations. Hidden pre keys are not included in the serialized state as returned by :attr:`model` and :attr:`json`. Args: pre_key_pub: The pre key to hide. Returns: Whether the pre key was visible before and is hidden now. """ hidden_pre_keys = frozenset(filter(lambda pre_key: pre_key.pub == pre_key_pub, self.__pre_keys)) self.__pre_keys -= hidden_pre_keys self.__hidden_pre_keys |= hidden_pre_keys return len(hidden_pre_keys) > 0 def delete_pre_key(self, pre_key_pub: bytes) -> bool: """ Delete a pre key. Args: pre_key_pub: The pre key to delete. Can be visible or hidden. Returns: Whether the pre key existed before and is deleted now. """ deleted_pre_keys = frozenset(filter( lambda pre_key: pre_key.pub == pre_key_pub, self.__pre_keys | self.__hidden_pre_keys )) self.__pre_keys -= deleted_pre_keys self.__hidden_pre_keys -= deleted_pre_keys return len(deleted_pre_keys) > 0 def delete_hidden_pre_keys(self) -> None: """ Delete all pre keys that were previously hidden using :meth:`hide_pre_key`. """ self.__hidden_pre_keys = set() def get_num_visible_pre_keys(self) -> int: """ Returns: The number of visible pre keys available. The number returned here matches the number of pre keys included in the bundle returned by :attr:`bundle`. """ return len(self.__pre_keys) def generate_pre_keys(self, num_pre_keys: int) -> None: """ Generate and store pre keys. Args: num_pre_keys: The number of pre keys to generate. """ for _ in range(num_pre_keys): self.__pre_keys.add(PreKeyPair(priv=secrets.token_bytes(32))) @property def bundle(self) -> Bundle: """ Returns: The bundle, i.e. the public information of this state. """ identity_key = self.__identity_key.as_priv().priv return Bundle( identity_key=( xeddsa.priv_to_curve25519_pub(identity_key) if self.__identity_key_format is IdentityKeyFormat.CURVE_25519 else xeddsa.priv_to_ed25519_pub(identity_key) ), signed_pre_key=self.__signed_pre_key.pub, signed_pre_key_sig=self.__signed_pre_key.sig, pre_keys=frozenset(pre_key.pub for pre_key in self.__pre_keys) ) ################# # key agreement # ################# async def get_shared_secret_active( self, bundle: Bundle, associated_data_appendix: bytes = b"", require_pre_key: bool = True ) -> Tuple[bytes, bytes, Header]: """ Perform an X3DH key agreement, actively. Args: bundle: The bundle of the passive party. associated_data_appendix: Additional information to append to the associated data, like usernames, certificates or other identifying information. require_pre_key: Use this flag to abort the key agreement if the bundle does not contain a pre key. Returns: The shared secret and associated data shared between both parties, and the header required by the other party to complete the passive part of the key agreement. Raises: KeyAgreementException: If an error occurs during the key agreement. The exception message will contain (human-readable) details. """ # Check whether a pre key is required but not included if len(bundle.pre_keys) == 0 and require_pre_key: raise KeyAgreementException("This bundle does not contain a pre key.") # Get the identity key of the other party in the format required for signature verification other_identity_key = bundle.identity_key if self.__identity_key_format is IdentityKeyFormat.CURVE_25519: other_identity_key = xeddsa.curve25519_pub_to_ed25519_pub(other_identity_key, False) # Verify the signature on the signed pre key of the other party if not xeddsa.ed25519_verify( bundle.signed_pre_key_sig, other_identity_key, self._encode_public_key(IdentityKeyFormat.CURVE_25519, bundle.signed_pre_key) ): raise KeyAgreementException("The signature of the signed pre key could not be verified.") # All pre-checks successful. # Choose a pre key if available pre_key = None if len(bundle.pre_keys) == 0 else secrets.choice(list(bundle.pre_keys)) # Generate the ephemeral key required for the key agreement ephemeral_key = secrets.token_bytes(32) # Get the own identity key in the format required for X25519 own_identity_key = self.__identity_key.as_priv().priv # Get the identity key of the other party in the format required for X25519 other_identity_key = bundle.identity_key if self.__identity_key_format is IdentityKeyFormat.ED_25519: other_identity_key = xeddsa.ed25519_pub_to_curve25519_pub(other_identity_key) # Calculate the three to four Diffie-Hellman shared secrets that become the input of HKDF in the next # step dh1 = xeddsa.x25519(own_identity_key, bundle.signed_pre_key) dh2 = xeddsa.x25519(ephemeral_key, other_identity_key) dh3 = xeddsa.x25519(ephemeral_key, bundle.signed_pre_key) dh4 = b"" if pre_key is None else xeddsa.x25519(ephemeral_key, pre_key) # Prepare salt and padding salt = b"\x00" * self.__hash_function.hash_size padding = b"\xFF" * 32 # Use HKDF to derive the final shared secret shared_secret = await CryptoProviderImpl.hkdf_derive( self.__hash_function, 32, salt, self.__info, padding + dh1 + dh2 + dh3 + dh4 ) # Build the associated data for further use by other protocols associated_data = ( self._encode_public_key(self.__identity_key_format, self.bundle.identity_key) + self._encode_public_key(self.__identity_key_format, bundle.identity_key) + associated_data_appendix ) # Build the header required by the other party to complete the passive part of the key agreement header = Header( identity_key=self.bundle.identity_key, ephemeral_key=xeddsa.priv_to_curve25519_pub(ephemeral_key), pre_key=pre_key, signed_pre_key=bundle.signed_pre_key ) return shared_secret, associated_data, header async def get_shared_secret_passive( self, header: Header, associated_data_appendix: bytes = b"", require_pre_key: bool = True ) -> Tuple[bytes, bytes, SignedPreKeyPair]: """ Perform an X3DH key agreement, passively. Args: header: The header received from the active party. associated_data_appendix: Additional information to append to the associated data, like usernames, certificates or other identifying information. require_pre_key: Use this flag to abort the key agreement if the active party did not use a pre key. Returns: The shared secret and the associated data shared between both parties, and the signed pre key pair that was used during the key exchange, for use by follow-up protocols. Raises: KeyAgreementException: If an error occurs during the key agreement. The exception message will contain (human-readable) details. """ # Check whether the signed pre key used by this initiation is still available signed_pre_key: Optional[SignedPreKeyPair] = None if header.signed_pre_key == self.__signed_pre_key.pub: # The current signed pre key was used signed_pre_key = self.__signed_pre_key if self.__old_signed_pre_key is not None and header.signed_pre_key == self.__old_signed_pre_key.pub: # The old signed pre key was used signed_pre_key = self.__old_signed_pre_key if signed_pre_key is None: raise KeyAgreementException( "This key agreement attempt uses a signed pre key that is not available any more." ) # Check whether a pre key is required but not used if header.pre_key is None and require_pre_key: raise KeyAgreementException("This key agreement attempt does not use a pre key.") # If a pre key was used, check whether it is still available pre_key: Optional[bytes] = None if header.pre_key is not None: pre_key = next(( pre_key.priv for pre_key in self.__pre_keys | self.__hidden_pre_keys if pre_key.pub == header.pre_key ), None) if pre_key is None: raise KeyAgreementException( "This key agreement attempt uses a pre key that is not available any more." ) # Get the own identity key in the format required for X25519 own_identity_key = self.__identity_key.as_priv().priv # Get the identity key of the other party in the format required for X25519 other_identity_key = header.identity_key if self.__identity_key_format is IdentityKeyFormat.ED_25519: other_identity_key = xeddsa.ed25519_pub_to_curve25519_pub(other_identity_key) # Calculate the three to four Diffie-Hellman shared secrets that become the input of HKDF in the next # step dh1 = xeddsa.x25519(signed_pre_key.priv, other_identity_key) dh2 = xeddsa.x25519(own_identity_key, header.ephemeral_key) dh3 = xeddsa.x25519(signed_pre_key.priv, header.ephemeral_key) dh4 = b"" if pre_key is None else xeddsa.x25519(pre_key, header.ephemeral_key) # Prepare salt and padding salt = b"\x00" * self.__hash_function.hash_size padding = b"\xFF" * 32 # Use HKDF to derive the final shared secret shared_secret = await CryptoProviderImpl.hkdf_derive( self.__hash_function, 32, salt, self.__info, padding + dh1 + dh2 + dh3 + dh4 ) # Build the associated data for further use by other protocols associated_data = ( self._encode_public_key(self.__identity_key_format, header.identity_key) + self._encode_public_key(self.__identity_key_format, self.bundle.identity_key) + associated_data_appendix ) return shared_secret, associated_data, signed_pre_key python-x3dh-1.0.3/x3dh/crypto_provider.py000066400000000000000000000026751433245423400203700ustar00rootroot00000000000000from abc import ABC, abstractmethod import enum from typing_extensions import assert_never __all__ = [ # pylint: disable=unused-variable "CryptoProvider", "HashFunction" ] @enum.unique class HashFunction(enum.Enum): """ Enumeration of the hash functions supported for the key derivation step of X3DH. """ SHA_256: str = "SHA_256" SHA_512: str = "SHA_512" @property def hash_size(self) -> int: """ Returns: The byte size of the hashes produced by this hash function. """ if self is HashFunction.SHA_256: return 32 if self is HashFunction.SHA_512: return 64 return assert_never(self) class CryptoProvider(ABC): """ Abstraction of the cryptographic operations needed by this package to allow for different backend implementations. """ @staticmethod @abstractmethod async def hkdf_derive( hash_function: HashFunction, length: int, salt: bytes, info: bytes, key_material: bytes ) -> bytes: """ Args: hash_function: The hash function to parameterize the HKDF with. length: The number of bytes to derive. salt: The salt input for the HKDF. info: The info input for the HKDF. key_material: The input key material to derive from. Returns: The derived key material. """ python-x3dh-1.0.3/x3dh/crypto_provider_cryptography.py000066400000000000000000000026331433245423400231750ustar00rootroot00000000000000from typing_extensions import assert_never from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.hkdf import HKDF from .crypto_provider import CryptoProvider, HashFunction __all__ = [ # pylint: disable=unused-variable "CryptoProviderImpl" ] def get_hash_algorithm(hash_function: HashFunction) -> hashes.HashAlgorithm: """ Args: hash_function: Identifier of a hash function. Returns: The implementation of the hash function as a cryptography :class:`~cryptography.hazmat.primitives.hashes.HashAlgorithm` object. """ if hash_function is HashFunction.SHA_256: return hashes.SHA256() if hash_function is HashFunction.SHA_512: return hashes.SHA512() return assert_never(hash_function) class CryptoProviderImpl(CryptoProvider): """ Cryptography provider based on the Python package `cryptography `_. """ @staticmethod async def hkdf_derive( hash_function: HashFunction, length: int, salt: bytes, info: bytes, key_material: bytes ) -> bytes: return HKDF( algorithm=get_hash_algorithm(hash_function), length=length, salt=salt, info=info, backend=default_backend() ).derive(key_material) python-x3dh-1.0.3/x3dh/identity_key_pair.py000066400000000000000000000131161433245423400206420ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable from abc import ABC, abstractmethod import json from typing import cast from typing_extensions import assert_never import xeddsa from .migrations import parse_identity_key_pair_model from .models import IdentityKeyPairModel from .types import JSONObject, SecretType __all__ = [ # pylint: disable=unused-variable "IdentityKeyPair", "IdentityKeyPairPriv", "IdentityKeyPairSeed" ] class IdentityKeyPair(ABC): """ An identity key pair. There are following requirements for the identity key pair: * It must be able to create and verify Ed25519-compatible signatures. * It must be able to perform X25519-compatible Diffie-Hellman key agreements. There are at least two different kinds of key pairs that can fulfill these requirements: Ed25519 key pairs and Curve25519 key pairs. The birational equivalence of both curves can be used to "convert" one pair to the other. Both types of key pairs share the same private key, however instead of a private key, a seed can be used which the private key is derived from using SHA-512. This is standard practice for Ed25519, where the other 32 bytes of the SHA-512 seed hash are used as a nonce during signing. If a new key pair has to be generated, this implementation generates a seed. """ @property def model(self) -> IdentityKeyPairModel: """ Returns: The internal state of this :class:`IdentityKeyPair` as a pydantic model. """ return IdentityKeyPairModel(secret=self.secret, secret_type=self.secret_type) @property def json(self) -> JSONObject: """ Returns: The internal state of this :class:`IdentityKeyPair` as a JSON-serializable Python object. """ return cast(JSONObject, json.loads(self.model.json())) @staticmethod def from_model(model: IdentityKeyPairModel) -> "IdentityKeyPair": """ Args: model: The pydantic model holding the internal state of an :class:`IdentityKeyPair`, as produced by :attr:`model`. Returns: A configured instance of :class:`IdentityKeyPair`, with internal state restored from the model. Warning: Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the documentation for details. """ if model.secret_type is SecretType.PRIV: return IdentityKeyPairPriv(model.secret) if model.secret_type is SecretType.SEED: return IdentityKeyPairSeed(model.secret) return assert_never(model.secret_type) @staticmethod def from_json(serialized: JSONObject) -> "IdentityKeyPair": """ Args: serialized: A JSON-serializable Python object holding the internal state of an :class:`IdentityKeyPair`, as produced by :attr:`json`. Returns: A configured instance of :class:`IdentityKeyPair`, with internal state restored from the serialized data. """ return IdentityKeyPair.from_model(parse_identity_key_pair_model(serialized)) @property @abstractmethod def secret_type(self) -> SecretType: """ Returns: The type of secret used by this identity key (i.e. a seed or private key). """ @property @abstractmethod def secret(self) -> bytes: """ Returns: The secret used by this identity key, i.e. the seed or private key. """ @abstractmethod def as_priv(self) -> "IdentityKeyPairPriv": """ Returns: An :class:`IdentityKeyPairPriv` derived from this instance, or the instance itself if it already is an :class:`IdentityKeyPairPriv`. """ class IdentityKeyPairPriv(IdentityKeyPair): """ An :class:`IdentityKeyPair` represented by a Curve25519/Ed25519 private key. """ def __init__(self, priv: bytes) -> None: """ Args: priv: The Curve25519/Ed25519 private key. """ if len(priv) != 32: raise ValueError("Expected the private key to be 32 bytes long.") self.__priv = priv @property def secret_type(self) -> SecretType: return SecretType.PRIV @property def secret(self) -> bytes: return self.priv def as_priv(self) -> "IdentityKeyPairPriv": return self @property def priv(self) -> bytes: """ Returns: The Curve25519/Ed25519 private key. """ return self.__priv class IdentityKeyPairSeed(IdentityKeyPair): """ An :class:`IdentityKeyPair` represented by a Curve25519/Ed25519 seed. """ def __init__(self, seed: bytes) -> None: """ Args: seed: The Curve25519/Ed25519 seed. """ if len(seed) != 32: raise ValueError("Expected the seed to be 32 bytes long.") self.__seed = seed @property def secret_type(self) -> SecretType: return SecretType.SEED @property def secret(self) -> bytes: return self.seed def as_priv(self) -> "IdentityKeyPairPriv": return IdentityKeyPairPriv(xeddsa.seed_to_priv(self.__seed)) @property def seed(self) -> bytes: """ Returns: The Curve25519/Ed25519 seed. """ return self.__seed python-x3dh-1.0.3/x3dh/migrations.py000066400000000000000000000131261433245423400173030ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable import base64 from typing import List, Tuple, cast from pydantic import BaseModel from .models import IdentityKeyPairModel, SignedPreKeyPairModel, BaseStateModel from .types import JSONObject, SecretType __all__ = [ # pylint: disable=unused-variable "parse_identity_key_pair_model", "parse_signed_pre_key_pair_model", "parse_base_state_model" ] class PreStableKeyPairModel(BaseModel): """ This model describes how a key pair was serialized in pre-stable serialization format. """ priv: str pub: str class PreStableSignedPreKeyModel(BaseModel): """ This model describes how a signed pre-key was serialized in pre-stable serialization format. """ key: PreStableKeyPairModel signature: str timestamp: float class PreStableModel(BaseModel): """ This model describes how State instances were serialized in pre-stable serialization format. """ changed: bool ik: PreStableKeyPairModel # pylint: disable=invalid-name spk: PreStableSignedPreKeyModel otpks: List[PreStableKeyPairModel] def parse_identity_key_pair_model(serialized: JSONObject) -> IdentityKeyPairModel: """ Parse a serialized :class:`~x3dh.identity_key_pair.IdentityKeyPair` instance, as returned by :attr:`~x3dh.identity_key_pair.IdentityKeyPair.json`, into the most recent pydantic model available for the class. Perform migrations in case the pydantic models were updated. Args: serialized: The serialized instance. Returns: The model, which can be used to restore the instance using :meth:`~x3dh.identity_key_pair.IdentityKeyPair.from_model`. Note: Pre-stable data can only be migrated as a whole using :func:`parse_base_state_model`. """ # Each model has a Python string "version" in its root. Use that to find the model that the data was # serialized from. version = cast(str, serialized["version"]) model: BaseModel = { "1.0.0": IdentityKeyPairModel, "1.0.1": IdentityKeyPairModel }[version](**serialized) # Once all migrations have been applied, the model should be an instance of the most recent model assert isinstance(model, IdentityKeyPairModel) return model def parse_signed_pre_key_pair_model(serialized: JSONObject) -> SignedPreKeyPairModel: """ Parse a serialized :class:`~x3dh.signed_pre_key_pair.SignedPreKeyPair` instance, as returned by :attr:`~x3dh.signed_pre_key_pair.SignedPreKeyPair.json`, into the most recent pydantic model available for the class. Perform migrations in case the pydantic models were updated. Args: serialized: The serialized instance. Returns: The model, which can be used to restore the instance using :meth:`~x3dh.signed_pre_key_pair.SignedPreKeyPair.from_model`. Note: Pre-stable data can only be migrated as a whole using :func:`parse_base_state_model`. """ # Each model has a Python string "version" in its root. Use that to find the model that the data was # serialized from. version = cast(str, serialized["version"]) model: BaseModel = { "1.0.0": SignedPreKeyPairModel, "1.0.1": SignedPreKeyPairModel }[version](**serialized) # Once all migrations have been applied, the model should be an instance of the most recent model assert isinstance(model, SignedPreKeyPairModel) return model def parse_base_state_model(serialized: JSONObject) -> Tuple[BaseStateModel, bool]: """ Parse a serialized :class:`~x3dh.base_state.BaseState` instance, as returned by :attr:`~x3dh.base_state.BaseState.json`, into the most recent pydantic model available for the class. Perform migrations in case the pydantic models were updated. Supports migration of pre-stable data. Args: serialized: The serialized instance. Returns: The model, which can be used to restore the instance using :meth:`~x3dh.base_state.BaseState.from_model`, and a flag that indicates whether the bundle needs to be published, which was part of the pre-stable serialization format. """ bundle_needs_publish = False # Each model has a Python string "version" in its root. Use that to find the model that the data was # serialized from. Special case: the pre-stable serialization format does not contain a version. version = cast(str, serialized["version"]) if "version" in serialized else None model: BaseModel = { None: PreStableModel, "1.0.0": BaseStateModel, "1.0.1": BaseStateModel }[version](**serialized) if isinstance(model, PreStableModel): # Run migrations from PreStableModel to StateModel bundle_needs_publish = bundle_needs_publish or model.changed model = BaseStateModel( identity_key=IdentityKeyPairModel( secret=base64.b64decode(model.ik.priv), secret_type=SecretType.PRIV ), signed_pre_key=SignedPreKeyPairModel( priv=base64.b64decode(model.spk.key.priv), sig=base64.b64decode(model.spk.signature), timestamp=int(model.spk.timestamp) ), old_signed_pre_key=None, pre_keys={ base64.b64decode(pre_key.priv) for pre_key in model.otpks } ) # Once all migrations have been applied, the model should be an instance of the most recent model assert isinstance(model, BaseStateModel) return model, bundle_needs_publish python-x3dh-1.0.3/x3dh/models.py000066400000000000000000000055661433245423400164230ustar00rootroot00000000000000from typing import Any, FrozenSet, Optional from pydantic import BaseModel, validator from .types import SecretType __all__ = [ # pylint: disable=unused-variable "BaseStateModel", "IdentityKeyPairModel", "SignedPreKeyPairModel" ] def _json_bytes_decoder(val: Any) -> bytes: """ Decode bytes from a string according to the JSON specification. See https://github.com/samuelcolvin/pydantic/issues/3756 for details. Args: val: The value to type check and decode. Returns: The value decoded to bytes. If the value is bytes already, it is returned unmodified. Raises: ValueError: if the value is not correctly encoded. """ if isinstance(val, bytes): return val if isinstance(val, str): return bytes(map(ord, val)) raise ValueError("bytes fields must be encoded as bytes or str.") def _json_bytes_encoder(val: bytes) -> str: """ Encode bytes as a string according to the JSON specification. See https://github.com/samuelcolvin/pydantic/issues/3756 for details. Args: val: The bytes to encode. Returns: The encoded bytes. """ return "".join(map(chr, val)) class IdentityKeyPairModel(BaseModel): """ The model representing the internal state of an :class:`~x3dh.identity_key_pair.IdentityKeyPair`. """ version: str = "1.0.0" secret: bytes secret_type: SecretType # Workaround for correct serialization of bytes, see :func:`bytes_decoder` above for details. class Config: # pylint: disable=missing-class-docstring json_encoders = { bytes: _json_bytes_encoder } _decoders = validator("secret", pre=True, allow_reuse=True)(_json_bytes_decoder) class SignedPreKeyPairModel(BaseModel): """ The model representing the internal state of a :class:`~x3dh.signed_pre_key_pair.SignedPreKeyPair`. """ version: str = "1.0.0" priv: bytes sig: bytes timestamp: int # Workaround for correct serialization of bytes, see :func:`bytes_decoder` above for details. class Config: # pylint: disable=missing-class-docstring json_encoders = { bytes: _json_bytes_encoder } _decoders = validator("priv", "sig", pre=True, allow_reuse=True)(_json_bytes_decoder) class BaseStateModel(BaseModel): """ The model representing the internal state of a :class:`~x3dh.base_state.BaseState`. """ version: str = "1.0.0" identity_key: IdentityKeyPairModel signed_pre_key: SignedPreKeyPairModel old_signed_pre_key: Optional[SignedPreKeyPairModel] pre_keys: FrozenSet[bytes] # Workaround for correct serialization of bytes, see :func:`bytes_decoder` above for details. class Config: # pylint: disable=missing-class-docstring json_encoders = { bytes: _json_bytes_encoder } _decoders = validator("pre_keys", pre=True, allow_reuse=True, each_item=True)(_json_bytes_decoder) python-x3dh-1.0.3/x3dh/pre_key_pair.py000066400000000000000000000005721433245423400176010ustar00rootroot00000000000000from typing import NamedTuple import xeddsa __all__ = [ # pylint: disable=unused-variable "PreKeyPair" ] class PreKeyPair(NamedTuple): """ A pre key. """ priv: bytes @property def pub(self) -> bytes: """ Returns: The public key of this pre key. """ return xeddsa.priv_to_curve25519_pub(self.priv) python-x3dh-1.0.3/x3dh/project.py000066400000000000000000000007161433245423400165760ustar00rootroot00000000000000__all__ = [ "project" ] # pylint: disable=unused-variable project = { "name" : "X3DH", "description" : "A Python implementation of the Extended Triple Diffie-Hellman key agreement protocol.", "url" : "https://github.com/Syndace/python-x3dh", "year" : "2022", "author" : "Tim Henkes (Syndace)", "author_email" : "me@syndace.dev", "categories" : [ "Topic :: Security :: Cryptography" ] } python-x3dh-1.0.3/x3dh/py.typed000066400000000000000000000000001433245423400162370ustar00rootroot00000000000000python-x3dh-1.0.3/x3dh/signed_pre_key_pair.py000066400000000000000000000053161433245423400211330ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable import json from typing import NamedTuple, cast import xeddsa from .migrations import parse_signed_pre_key_pair_model from .models import SignedPreKeyPairModel from .types import JSONObject __all__ = [ # pylint: disable=unused-variable "SignedPreKeyPair" ] class SignedPreKeyPair(NamedTuple): """ A signed pre key, i.e. a pre key whose public key was encoded using an application-specific encoding format, then signed by the identity key, and stored together with a generation timestamp for periodic rotation. """ priv: bytes sig: bytes timestamp: int @property def pub(self) -> bytes: """ Returns: The public key of this signed pre key. """ return xeddsa.priv_to_curve25519_pub(self.priv) @property def model(self) -> SignedPreKeyPairModel: """ Returns: The internal state of this :class:`SignedPreKeyPair` as a pydantic model. """ return SignedPreKeyPairModel(priv=self.priv, sig=self.sig, timestamp=self.timestamp) @property def json(self) -> JSONObject: """ Returns: The internal state of this :class:`SignedPreKeyPair` as a JSON-serializable Python object. """ return cast(JSONObject, json.loads(self.model.json())) @staticmethod def from_model(model: SignedPreKeyPairModel) -> "SignedPreKeyPair": """ Args: model: The pydantic model holding the internal state of a :class:`SignedPreKeyPair`, as produced by :attr:`model`. Returns: A configured instance of :class:`SignedPreKeyPair`, with internal state restored from the model. Warning: Migrations are not provided via the :attr:`model`/:meth:`from_model` API. Use :attr:`json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the documentation for details. """ return SignedPreKeyPair(priv=model.priv, sig=model.sig, timestamp=model.timestamp) @staticmethod def from_json(serialized: JSONObject) -> "SignedPreKeyPair": """ Args: serialized: A JSON-serializable Python object holding the internal state of a :class:`SignedPreKeyPair`, as produced by :attr:`json`. Returns: A configured instance of :class:`SignedPreKeyPair`, with internal state restored from the serialized data. """ return SignedPreKeyPair.from_model(parse_signed_pre_key_pair_model(serialized)) python-x3dh-1.0.3/x3dh/state.py000066400000000000000000000304761433245423400162560ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable from abc import abstractmethod from typing import Any, Optional, Tuple, Type, TypeVar from .base_state import BaseState from .crypto_provider import HashFunction from .identity_key_pair import IdentityKeyPair from .migrations import parse_base_state_model from .models import BaseStateModel from .signed_pre_key_pair import SignedPreKeyPair from .types import Bundle, IdentityKeyFormat, Header, JSONObject __all__ = [ # pylint: disable=unused-variable "State" ] StateTypeT = TypeVar("StateTypeT", bound="State") class State(BaseState): """ This class is the core of this X3DH implementation. It manages the own :class:`~x3dh.types.Bundle` and offers methods to perform key agreements with other parties. Use :class:`~x3dh.base_state.BaseState` directly if manual state management is needed. Note that you can still use the methods available for manual state management, but doing so shouldn't be required. Warning: :meth:`rotate_signed_pre_key` should be called periodically to check whether the signed pre key needs to be rotated and to perform the rotation if necessary. """ def __init__(self) -> None: super().__init__() # Just the type definitions here self.__signed_pre_key_rotation_period: int self.__pre_key_refill_threshold: int self.__pre_key_refill_target: int @classmethod def create( cls: Type[StateTypeT], identity_key_format: IdentityKeyFormat, hash_function: HashFunction, info: bytes, identity_key_pair: Optional[IdentityKeyPair] = None, signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60, pre_key_refill_threshold: int = 99, pre_key_refill_target: int = 100 ) -> StateTypeT: """ Args: identity_key_format: The format in which the identity public key is included in bundles/headers. hash_function: A 256 or 512-bit hash function. info: A (byte) string identifying the application. signed_pre_key_rotation_period: Rotate the signed pre key after this amount of time in seconds. pre_key_refill_threshold: Threshold for refilling the pre keys. pre_key_refill_target: When less then ``pre_key_refill_threshold`` pre keys are available, generate new ones until there are ``pre_key_refill_target`` pre keys again. identity_key_pair: If set, use the given identity key pair instead of generating a new one. Returns: A configured instance of :class:`~x3dh.state.State`. """ # pylint: disable=protected-access if signed_pre_key_rotation_period < 1: raise ValueError( "Invalid value passed for the `signed_pre_key_rotation_period` parameter. The signed pre key" " rotation period must be at least one day." ) if not 1 <= pre_key_refill_threshold <= pre_key_refill_target: raise ValueError( "Invalid value(s) passed for the `pre_key_refill_threshold` / `pre_key_refill_target`" " parameter(s). `pre_key_refill_threshold` must be greater than or equal to '1' and lower" " than or equal to `pre_key_refill_target`." ) self = super().create(identity_key_format, hash_function, info, identity_key_pair) self.__signed_pre_key_rotation_period = signed_pre_key_rotation_period self.__pre_key_refill_threshold = pre_key_refill_threshold self.__pre_key_refill_target = pre_key_refill_target self.generate_pre_keys(pre_key_refill_target) # I believe this is a false positive by pylint self._publish_bundle(self.bundle) # pylint: disable=no-member return self #################### # abstract methods # #################### @abstractmethod def _publish_bundle(self, bundle: Bundle) -> Any: """ Args: bundle: The bundle to publish, overwriting previously published data. Returns: Anything, the return value is ignored. Note: In addition to publishing the bundle, this method can be used as a trigger to persist the state. Persisting the state in this method guarantees always remaining up-to-date. Note: This method is called from :meth:`create`, before :meth:`create` has returned the instance. Thus, modifications to the object (``self``, in case of subclasses) may not have happened when this method is called. Note: Even though this method is expected to perform I/O, it is deliberately not marked as async, since completion of the I/O operation is not a requirement for the program flow to continue, and making this method async would complicate API design with regards to inheritance from :class:`~x3dh.base_state.BaseState`. """ raise NotImplementedError("Create a subclass of State and implement `_publish_bundle`.") ################# # serialization # ################# @classmethod def from_model( cls: Type[StateTypeT], model: BaseStateModel, identity_key_format: IdentityKeyFormat, hash_function: HashFunction, info: bytes, signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60, pre_key_refill_threshold: int = 99, pre_key_refill_target: int = 100 ) -> StateTypeT: """ Args: model: The pydantic model holding the internal state of a :class:`State`, as produced by :attr:`~x3dh.base_state.BaseState.model`. identity_key_format: The format in which the identity public key is included in bundles/headers. hash_function: A 256 or 512-bit hash function. info: A (byte) string identifying the application. signed_pre_key_rotation_period: Rotate the signed pre key after this amount of time in seconds. pre_key_refill_threshold: Threshold for refilling the pre keys. pre_key_refill_target: When less then ``pre_key_refill_threshold`` pre keys are available, generate new ones until there are ``pre_key_refill_target`` pre keys again. Returns: A configured instance of :class:`State`, with internal state restored from the model. Warning: Migrations are not provided via the :attr:`~x3dh.base_state.BaseState.model`/:meth:`from_model` API. Use :attr:`~x3dh.base_state.BaseState.json`/:meth:`from_json` instead. Refer to :ref:`serialization_and_migration` in the documentation for details. """ # pylint: disable=protected-access if signed_pre_key_rotation_period < 1: raise ValueError( "Invalid value passed for the `signed_pre_key_rotation_period` parameter. The signed pre key" " rotation period must be at least one day." ) if not 1 <= pre_key_refill_threshold <= pre_key_refill_target: raise ValueError( "Invalid value(s) passed for the `pre_key_refill_threshold` / `pre_key_refill_target`" " parameter(s). `pre_key_refill_threshold` must be greater than or equal to '1' and lower" " than or equal to `pre_key_refill_target`." ) self = super().from_model(model, identity_key_format, hash_function, info) self.__signed_pre_key_rotation_period = signed_pre_key_rotation_period self.__pre_key_refill_threshold = pre_key_refill_threshold self.__pre_key_refill_target = pre_key_refill_target self.rotate_signed_pre_key() return self @classmethod def from_json( cls: Type[StateTypeT], serialized: JSONObject, identity_key_format: IdentityKeyFormat, hash_function: HashFunction, info: bytes, signed_pre_key_rotation_period: int = 7 * 24 * 60 * 60, pre_key_refill_threshold: int = 99, pre_key_refill_target: int = 100 ) -> Tuple[StateTypeT, bool]: """ Args: serialized: A JSON-serializable Python object holding the internal state of a :class:`State`, as produced by :attr:`~x3dh.base_state.BaseState.json`. identity_key_format: The format in which the identity public key is included in bundles/headers. hash_function: A 256 or 512-bit hash function. info: A (byte) string identifying the application. signed_pre_key_rotation_period: Rotate the signed pre key after this amount of time in seconds. pre_key_refill_threshold: Threshold for refilling the pre keys. pre_key_refill_target: When less then ``pre_key_refill_threshold`` pre keys are available, generate new ones until there are ``pre_key_refill_target`` pre keys again. Returns: A configured instance of :class:`State`, with internal state restored from the serialized data, and a flag that indicates whether the bundle needed to be published. The latter was part of the pre-stable serialization format and is handled automatically by this :meth:`from_json` implementation. """ # pylint: disable=protected-access model, bundle_needs_publish = parse_base_state_model(serialized) self = cls.from_model( model, identity_key_format, hash_function, info, signed_pre_key_rotation_period, pre_key_refill_threshold, pre_key_refill_target ) if bundle_needs_publish: # I believe this is a false positive by pylint self._publish_bundle(self.bundle) # pylint: disable=no-member return self, False ################################# # key generation and management # ################################# def rotate_signed_pre_key(self, force: bool = False) -> None: """ Check whether the signed pre key is due for rotation, and rotate it if necessary. Call this method periodically to make sure the signed pre key is always up to date. Args: force: Whether to force rotation regardless of the age of the current signed pre key. """ if force or self.signed_pre_key_age() > self.__signed_pre_key_rotation_period: super().rotate_signed_pre_key() self._publish_bundle(self.bundle) ################# # key agreement # ################# async def get_shared_secret_passive( self, header: Header, associated_data_appendix: bytes = b"", require_pre_key: bool = True ) -> Tuple[bytes, bytes, SignedPreKeyPair]: """ Perform an X3DH key agreement, passively. Args: header: The header received from the active party. associated_data_appendix: Additional information to append to the associated data, like usernames, certificates or other identifying information. require_pre_key: Use this flag to abort the key agreement if the active party did not use a pre key. Returns: The shared secret and the associated data shared between both parties, and the signed pre key pair that was used during the key exchange, for use by follow-up protocols. Raises: KeyAgreementException: If an error occurs during the key agreement. The exception message will contain (human-readable) details. """ shared_secret, associated_data, signed_pre_key_pair = await super().get_shared_secret_passive( header, associated_data_appendix, require_pre_key ) # If a pre key was used, remove it from the pool and refill the pool if necessary if header.pre_key is not None: self.delete_pre_key(header.pre_key) if self.get_num_visible_pre_keys() < self.__pre_key_refill_threshold: self.generate_pre_keys(self.__pre_key_refill_target - self.get_num_visible_pre_keys()) self._publish_bundle(self.bundle) return shared_secret, associated_data, signed_pre_key_pair python-x3dh-1.0.3/x3dh/types.py000066400000000000000000000060351433245423400162740ustar00rootroot00000000000000# This import from future (theoretically) enables sphinx_autodoc_typehints to handle type aliases better from __future__ import annotations # pylint: disable=unused-variable import enum from typing import FrozenSet, List, Mapping, NamedTuple, Optional, Union __all__ = [ # pylint: disable=unused-variable "Bundle", "IdentityKeyFormat", "Header", "JSONObject", "SecretType" ] ################ # Type Aliases # ################ # # Thanks @vanburgerberg - https://github.com/python/typing/issues/182 # if TYPE_CHECKING: # class JSONArray(list[JSONType], Protocol): # type: ignore # __class__: Type[list[JSONType]] # type: ignore # # class JSONObject(dict[str, JSONType], Protocol): # type: ignore # __class__: Type[dict[str, JSONType]] # type: ignore # # JSONType = Union[None, float, int, str, bool, JSONArray, JSONObject] # Sadly @vanburgerberg's solution doesn't seem to like Dict[str, bool], thus for now an incomplete JSON # type with finite levels of depth. Primitives = Union[None, float, int, str, bool] JSONType1 = Union[Primitives, List[Primitives], Mapping[str, Primitives]] JSONType = Union[Primitives, List[JSONType1], Mapping[str, JSONType1]] JSONObject = Mapping[str, JSONType] ############################ # Structures (NamedTuples) # ############################ class Bundle(NamedTuple): """ The bundle is a collection of public keys and signatures used by the X3DH protocol to achieve asynchronous key agreements while providing forward secrecy and cryptographic deniability. Parties that want to be available for X3DH key agreements have to publish their bundle somehow. Other parties can then use that bundle to perform a key agreement. """ identity_key: bytes signed_pre_key: bytes signed_pre_key_sig: bytes pre_keys: FrozenSet[bytes] class Header(NamedTuple): """ The header generated by the active party as part of the key agreement, and consumed by the passive party to derive the same shared secret. """ identity_key: bytes ephemeral_key: bytes signed_pre_key: bytes pre_key: Optional[bytes] ################ # Enumerations # ################ @enum.unique class IdentityKeyFormat(enum.Enum): """ The two supported public key formats for the identity key: * Curve25519 public keys: 32 bytes, the little-endian encoding of the u coordinate as per `RFC 7748, section 5 "The X25519 and X448 Functions" `_. * Ed25519 public keys: 32 bytes, the little-endian encoding of the y coordinate with the sign bit of the x coordinate stored in the most significant bit as per `RFC 8032, section 3.2 "Keys" `_. """ CURVE_25519: str = "CURVE_25519" ED_25519: str = "ED_25519" @enum.unique class SecretType(enum.Enum): """ The two types of secrets that an :class:`IdentityKeyPair` can use internally: a seed or a private key. """ SEED: str = "SEED" PRIV: str = "PRIV" python-x3dh-1.0.3/x3dh/version.py000066400000000000000000000003231433245423400166070ustar00rootroot00000000000000__all__ = [ "__version__" ] # pylint: disable=unused-variable __version__ = {} __version__["short"] = "1.0.3" __version__["tag"] = "stable" __version__["full"] = f"{__version__['short']}-{__version__['tag']}"