├── .coveragerc ├── .gitignore ├── .readthedocs.yaml ├── .woodpecker.yml ├── CHANGELOG.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── dev-requirements.txt ├── docs ├── Makefile ├── _static │ ├── .placeholder │ ├── generic_diagram.odg │ └── generic_diagram.png ├── _templates │ └── .placeholder ├── changelog.md ├── conf.py ├── development.rst ├── example_projects.rst ├── index.rst ├── install.rst ├── introduction.rst ├── make.bat ├── protocols.rst └── usage.rst ├── federation ├── __init__.py ├── django │ ├── __init__.py │ └── urls.py ├── entities │ ├── __init__.py │ ├── activitypub │ │ ├── __init__.py │ │ ├── constants.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── views.py │ │ ├── enums.py │ │ ├── ldcontext.py │ │ ├── ldsigning.py │ │ ├── mappers.py │ │ └── models.py │ ├── base.py │ ├── diaspora │ │ ├── __init__.py │ │ ├── entities.py │ │ ├── mappers.py │ │ ├── mixins.py │ │ └── utils.py │ ├── matrix │ │ ├── __init__.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ ├── urls.py │ │ │ └── views.py │ │ ├── entities.py │ │ ├── enums.py │ │ └── mappers.py │ ├── mixins.py │ └── utils.py ├── exceptions.py ├── fetchers.py ├── hostmeta │ ├── __init__.py │ ├── django │ │ ├── __init__.py │ │ ├── generators.py │ │ └── urls.py │ ├── fetchers.py │ ├── generators.py │ ├── parsers.py │ ├── schemas │ │ ├── nodeinfo-1.0.json │ │ └── social-relay-well-known.json │ └── templates │ │ └── hcard_diaspora.html ├── inbound.py ├── outbound.py ├── protocols │ ├── __init__.py │ ├── activitypub │ │ ├── __init__.py │ │ ├── protocol.py │ │ └── signing.py │ ├── diaspora │ │ ├── __init__.py │ │ ├── encrypted.py │ │ ├── magic_envelope.py │ │ ├── protocol.py │ │ └── signatures.py │ └── matrix │ │ ├── __init__.py │ │ ├── appservice.py │ │ └── protocol.py ├── tests │ ├── __init__.py │ ├── conftest.py │ ├── django │ │ ├── __init__.py │ │ ├── settings.py │ │ └── utils.py │ ├── entities │ │ ├── __init__.py │ │ ├── activitypub │ │ │ ├── __init__.py │ │ │ ├── django │ │ │ │ ├── __init__.py │ │ │ │ └── test_views.py │ │ │ ├── test_entities.py │ │ │ └── test_mappers.py │ │ ├── diaspora │ │ │ ├── __init__.py │ │ │ ├── test_entities.py │ │ │ ├── test_mappers.py │ │ │ └── test_utils.py │ │ ├── matrix │ │ │ └── __init__.py │ │ └── test_base.py │ ├── factories │ │ ├── __init__.py │ │ └── entities.py │ ├── fixtures │ │ ├── __init__.py │ │ ├── entities.py │ │ ├── hostmeta.py │ │ ├── keys.py │ │ ├── payloads │ │ │ ├── __init__.py │ │ │ ├── activitypub.py │ │ │ └── diaspora.py │ │ └── types.py │ ├── hostmeta │ │ ├── __init__.py │ │ ├── django │ │ │ ├── __init__.py │ │ │ └── test_generators.py │ │ ├── test_fetchers.py │ │ ├── test_generators.py │ │ └── test_parsers.py │ ├── protocols │ │ ├── __init__.py │ │ ├── activitypub │ │ │ ├── __init__.py │ │ │ ├── test_protocol.py │ │ │ └── test_signing.py │ │ ├── diaspora │ │ │ ├── __init__.py │ │ │ ├── test_encrypted.py │ │ │ ├── test_magic_envelope.py │ │ │ ├── test_protocol.py │ │ │ └── test_signatures.py │ │ └── matrix │ │ │ ├── __init__.py │ │ │ ├── test_appservice.py │ │ │ └── test_protocol.py │ ├── test_fetchers.py │ ├── test_inbound.py │ ├── test_outbound.py │ └── utils │ │ ├── __init__.py │ │ ├── test_activitypub.py │ │ ├── test_diaspora.py │ │ ├── test_network.py │ │ ├── test_protocol.py │ │ └── test_text.py ├── types.py └── utils │ ├── __init__.py │ ├── activitypub.py │ ├── diaspora.py │ ├── django.py │ ├── matrix.py │ ├── network.py │ ├── protocols.py │ └── text.py ├── pytest.ini ├── setup.cfg ├── setup.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | setup.py 4 | federation/__init__.py 5 | */tests/* 6 | .tox/* 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | __pycache__ 3 | .cache 4 | .pytest_cache/ 5 | 6 | http_cache.sqlite 7 | database.sqlite 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Packages 13 | *.egg 14 | *.eggs 15 | *.egg-info 16 | dist 17 | build 18 | eggs 19 | parts 20 | bin 21 | var 22 | sdist 23 | develop-eggs 24 | .installed.cfg 25 | lib 26 | lib64 27 | 28 | # Installer logs 29 | pip-log.txt 30 | 31 | # Unit test / coverage reports 32 | .coverage 33 | .tox 34 | nosetests.xml 35 | 36 | # Translations 37 | *.mo 38 | 39 | # Mr Developer 40 | .mr.developer.cfg 41 | .project 42 | .pydevproject 43 | 44 | # PyCharm 45 | .idea/ 46 | 47 | # Docs 48 | docs/_build 49 | 50 | # LO lock file 51 | .~lock.* 52 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-22.04 11 | tools: 12 | python: "3.9" 13 | 14 | # Build documentation in the docs/ directory with Sphinx 15 | sphinx: 16 | configuration: docs/conf.py 17 | 18 | formats: all 19 | 20 | python: 21 | install: 22 | - requirements: dev-requirements.txt 23 | -------------------------------------------------------------------------------- /.woodpecker.yml: -------------------------------------------------------------------------------- 1 | steps: 2 | test: 3 | image: python:3.10 4 | commands: 5 | - python -V 6 | - pip install virtualenv 7 | - virtualenv venv 8 | - . venv/bin/activate 9 | - pip install tox 10 | - tox 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Jason Robinson 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 11 | 12 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 13 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include federation/hostmeta/templates/*.html 2 | include docs/introduction.rst 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![issue tracker](https://img.shields.io/badge/issue%20tracker-codeberg-orange.svg)](https://codeberg.org/socialhome/federation/issues) 2 | 3 | [![PyPI version](https://badge.fury.io/py/federation.svg)](https://pypi.python.org/pypi/federation) [![Documentation Status](http://readthedocs.org/projects/federation/badge/?version=latest)](http://federation.readthedocs.io/en/latest/?badge=latest) [![PyPI](https://img.shields.io/pypi/pyversions/federation.svg?maxAge=2592000)](https://pypi.python.org/pypi/federation) [![PyPI](https://img.shields.io/pypi/l/federation.svg?maxAge=2592000)](https://pypi.python.org/pypi/federation) 4 | 5 | # federation 6 | 7 | Python library to abstract social web federation protocols like ActivityPub, Diaspora and Matrix. 8 | 9 | ## Introduction 10 | 11 | The aim of `federation` is to provide and abstract multiple social web protocols like 12 | ActivityPub, Diaspora and Matrix in one package, over an easy to use and understand Python API. 13 | This way applications can be built to (almost) transparently support many protocols 14 | without the app builder having to know everything about those protocols. 15 | 16 | ![](./docs/_static/generic_diagram.png) 17 | 18 | ## Status 19 | 20 | Currently, three protocols are being focused on. 21 | 22 | * Diaspora is considered to be stable with most of the protocol implemented. 23 | * ActivityPub is considered to be stable with working federation with most ActivityPub platforms. 24 | * Matrix support is in early phase and not to be considered useful yet. 25 | 26 | The code base is well tested and in use in several projects. Backward incompatible changes 27 | will be clearly documented in changelog entries. 28 | 29 | ## Additional information 30 | 31 | ### Installation and requirements 32 | 33 | See [installation documentation](http://federation.readthedocs.io/en/latest/install.html). 34 | 35 | ### Usage and API documentation 36 | 37 | See [usage documentation](http://federation.readthedocs.io/en/latest/usage.html). 38 | 39 | ### Support and help 40 | 41 | See [development and support documentation](http://federation.readthedocs.io/en/latest/development.html). 42 | 43 | ### License 44 | 45 | [BSD 3-clause license](https://www.tldrlegal.com/l/bsd3) 46 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | ## Requirements for local development 2 | 3 | # Package deps from setup.py 4 | -e . 5 | 6 | # Tests 7 | pytest 8 | factory_boy 9 | codecov 10 | coverage 11 | pytest-blockage 12 | pytest-cov 13 | pytest-warnings 14 | tox 15 | 16 | # Docs 17 | sphinx 18 | sphinx-autobuild 19 | recommonmark 20 | 21 | # Some datetime magic 22 | arrow 23 | freezegun 24 | 25 | # Django support 26 | django>=3.2,<4 27 | pytest-django 28 | 29 | # Releasing 30 | twine 31 | -------------------------------------------------------------------------------- /docs/_static/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/docs/_static/.placeholder -------------------------------------------------------------------------------- /docs/_static/generic_diagram.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/docs/_static/generic_diagram.odg -------------------------------------------------------------------------------- /docs/_static/generic_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/docs/_static/generic_diagram.png -------------------------------------------------------------------------------- /docs/_templates/.placeholder: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/docs/_templates/.placeholder -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ../CHANGELOG.md -------------------------------------------------------------------------------- /docs/development.rst: -------------------------------------------------------------------------------- 1 | Development 2 | =========== 3 | 4 | Help is more than welcome to extend this library. Please see the following resources. 5 | 6 | * `Source code repo `_ 7 | * `Issue tracker `_ 8 | 9 | NOTE! Due to bugs in the GitLab -> Codeberg migration tool, old issues before October 2024 can 10 | only be found in the old [GitLab issue tracker](https://gitlab.com/jaywink/federation/-/issues). 11 | 12 | Environment setup 13 | ----------------- 14 | 15 | Once you have your (Python 3.7+) virtualenv set up, install the development requirements:: 16 | 17 | pip install -r dev-requirements.txt 18 | 19 | Running tests 20 | ------------- 21 | 22 | :: 23 | 24 | py.test 25 | 26 | Building local documentation 27 | ---------------------------- 28 | 29 | :: 30 | 31 | cd docs 32 | make html 33 | 34 | Built documentation is available at ``docs/_build/html/index.html``. 35 | 36 | Releasing 37 | --------- 38 | 39 | :: 40 | 41 | pip install -U build twine 42 | python -m build 43 | python -m twine upload dist/federation-* 44 | 45 | Contact for help 46 | ---------------- 47 | 48 | Easiest via Matrix on room ``#socialhome:federator.dev``. 49 | 50 | You can also ask questions or give feedback via issues. 51 | -------------------------------------------------------------------------------- /docs/example_projects.rst: -------------------------------------------------------------------------------- 1 | .. _example-projects: 2 | 3 | Projects using federation 4 | ------------------------- 5 | 6 | For examples on how to integrate this library into your project, check these examples: 7 | 8 | * `Socialhome `_ - a federated home page builder slash personal social network server with high emphasis on card style content visualization. 9 | * `Social-Relay `_ - a reference server for the public content relay system that uses the Diaspora protocol. 10 | * `The Federation info `_ - statistics and node list for the federated web. 11 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. federation documentation master file, created by 2 | sphinx-quickstart on Sun Oct 2 12:42:19 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | federation 7 | ========== 8 | 9 | Python library to abstract social web federation protocols like ActivityPub and Diaspora. 10 | 11 | Contents: 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | introduction 17 | install 18 | protocols 19 | usage 20 | development 21 | example_projects 22 | changelog 23 | 24 | 25 | Indices and tables 26 | ================== 27 | 28 | * :ref:`genindex` 29 | * :ref:`modindex` 30 | * :ref:`search` 31 | 32 | -------------------------------------------------------------------------------- /docs/install.rst: -------------------------------------------------------------------------------- 1 | Install 2 | ======= 3 | 4 | Dependencies 5 | ------------ 6 | 7 | Depending on your operating system, certain dependencies will need to be installed. 8 | 9 | lxml 10 | .... 11 | 12 | lxml itself is installed by pip but the dependencies need to be installed `as per lxml instructions `_. 13 | 14 | Installation 15 | ------------ 16 | 17 | Install with pip or include in your requirements file. 18 | 19 | :: 20 | 21 | pip install federation 22 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | The aim of ``federation`` is to provide and abstract multiple social web protocols like 5 | ActivityPub and Diaspora in one package, over an easy to use and understand Python API. 6 | This way applications can be built to (almost) transparently support many protocols 7 | without the app builder having to know everything about those protocols. 8 | 9 | .. image:: _static/generic_diagram.png 10 | 11 | Status 12 | ------ 13 | 14 | Currently three protocols are being focused on. 15 | 16 | * Diaspora is considered to be stable with most of the protocol implemented. 17 | * ActivityPub support should be considered as beta - inbound payload are 18 | handled by a jsonld processor (calamus) 19 | * Matrix support cannot be considered usable as of yet. 20 | 21 | The code base is well tested and in use in several projects. Backward incompatible changes 22 | will be clearly documented in changelog entries. 23 | 24 | Additional information 25 | ---------------------- 26 | 27 | Installation and requirements 28 | ............................. 29 | 30 | See `installation documentation `_. 31 | 32 | Usage and API documentation 33 | ........................... 34 | 35 | See `usage documentation `_. 36 | 37 | Support and help 38 | ................ 39 | 40 | See `development and support documentation `_. 41 | 42 | License 43 | ....... 44 | 45 | `BSD 3-clause license `_. 46 | -------------------------------------------------------------------------------- /docs/protocols.rst: -------------------------------------------------------------------------------- 1 | Protocols 2 | ========= 3 | 4 | Currently three protocols are being focused on. 5 | 6 | * Diaspora is considered to be stable with most of the protocol implemented. 7 | * ActivityPub support should be considered as beta - all the basic 8 | things work and we are fixing incompatibilities as they are identified. 9 | * Matrix support cannot be considered usable as of yet. 10 | 11 | For example implementations in real life projects check :ref:`example-projects`. 12 | 13 | .. _diaspora: 14 | 15 | Diaspora 16 | -------- 17 | 18 | This library only supports the `current renewed version `_ of the protocol. Compatibility for the legacy version was dropped in version 0.18.0. 19 | 20 | The feature set supported is the following: 21 | 22 | * Webfinger, hCard and other discovery documents 23 | * NodeInfo 1.0 documents 24 | * Social-Relay documents 25 | * Magic envelopes, signatures and other transport method related necessities 26 | * Entities as follows: 27 | 28 | * Comment 29 | * Like 30 | * Photo 31 | * Profile 32 | * Retraction 33 | * StatusMessage 34 | * Contact 35 | * Reshare 36 | 37 | .. _activitypub: 38 | 39 | ActivityPub 40 | ----------- 41 | 42 | Features currently supported: 43 | 44 | * Webfinger 45 | * Objects and activities as follows: 46 | 47 | * Actor (Person outbound, Person, Organization, Service inbound) 48 | * Note, Article and Page (Create, Delete, Update) 49 | * These become a ``Post`` or ``Comment`` depending on ``inReplyTo``. 50 | * Attachment images, (inbound only for audios and videos) from the above objects 51 | * Follow, Accept Follow, Undo Follow 52 | * Announce 53 | * Inbound Peertube Video objects translated as ``Post``. 54 | 55 | * Inbound processing of reply collections, for platforms that implement it. 56 | * Link, Like, View, Signature, PropertyValue, IdentityProof and Emojis objects are only processed for inbound 57 | payloads currently. Outbound processing requires support by the client 58 | application. 59 | 60 | Namespace 61 | ......... 62 | 63 | All payloads over ActivityPub sent can be identified with by checking ``@context`` which will include the ``pyfed: https://docs.jasonrobinson.me/ns/python-federation`` namespace. 64 | 65 | Content media type 66 | .................. 67 | 68 | The following keys will be set on the entity based on the ``source`` property existing: 69 | 70 | * if the object has an ``object.source`` property: 71 | * ``_media_type`` will be the source media type (only text/markdown is supported). 72 | * ``rendered_content`` will be the object ``content`` 73 | * ``raw_content`` will be the source ``content`` 74 | * if the object has no ``object.source`` property: 75 | * ``_media_type`` will be ``text/html`` 76 | * ``rendered_content`` will be the object ``content`` 77 | * ``raw_content`` will be empty 78 | 79 | The ``contentMap`` property is processed but content language selection is not implemented yet. 80 | 81 | For outbound entities, ``raw_content`` is expected to be in ``text/markdown``, 82 | specifically CommonMark. The client applications are expected to provide the 83 | rendered content for protocols that require it (e.g. ActivityPub). 84 | When sending payloads, ``object.contentMap`` will be set to ``rendered_content`` 85 | and ``raw_content`` will be added to the ``object.source`` property. 86 | 87 | Medias 88 | ...... 89 | 90 | Any images referenced in the ``raw_content`` of outbound entities will be extracted 91 | into ``object.attachment`` object. For receivers that don't support inline images, 92 | image attachments will have a ``pyfed:inlineImage`` property set to ``true`` to 93 | indicate the image has been extracted from the content. Receivers should ignore the 94 | inline image attachments if they support showing ```` HTML tags or the markdown 95 | content in ``object.source``. Outbound audio and video attachments currently lack 96 | support from client applications. 97 | 98 | For inbound entities we do this automatically by not including received image attachments in 99 | the entity ``_children`` attribute. Audio and video are passed through the client application. 100 | 101 | Hashtags and mentions 102 | ..................... 103 | 104 | For outbound payloads, client applications must add/set the hashtag/mention value to 105 | the ``class`` attribute of rendered content linkified hashtags/mentions. These will be 106 | used to help build the corresponding ``Hashtag`` and ``Mention`` objects. 107 | 108 | For inbound payloads, if a markdown source is provided, hashtags/mentions will be extracted 109 | through the same method used for Diaspora. If only HTML content is provided, the ``a`` tags 110 | will be marked with a ``data-[hashtag|mention]`` attribute (based on the provided Hashtag/Mention 111 | objects) to facilitate the ``href`` attribute modifications lient applications might 112 | wish to make. This should ensure links can be replaced regardless of how the HTML is structured. 113 | 114 | .. _matrix: 115 | 116 | Matrix 117 | ------ 118 | 119 | The aim of Matrix support in this library is not to provide instant messaging but to wrap 120 | the parts of the Matrix protocol that specifically are especially useful for social media 121 | applications. The current ongoing work on `Ceruelan `_ 122 | provides much of what will be implemented in this library. 123 | 124 | This library doesn't aim to be a homeserver or provide any part of the server to server API. 125 | The plan is to provide an appservice to hook onto a separate homeserver that deals with all 126 | the complex protocol related details. This library will then aim to abstract much of what the 127 | appservice gives or takes behind the same API as is provided for the other protocols. 128 | 129 | Currently support is being added, please visit back in future versions. 130 | 131 | NOTE! Current features also assume Django is configured, though this is likely to not be 132 | the case in the future. 133 | 134 | Appservice 135 | .......... 136 | 137 | To generate the appservice registration file you must ensure you've added the relevant 138 | configuration (see :ref:`usage-configuration`). 139 | 140 | Then launch a Django shell inside your project and run the following: 141 | 142 | :: 143 | 144 | from federation.protocols.matrix.appservice import print_registration_yaml 145 | print_registration_yaml() 146 | 147 | This YAML needs to be registered with the linked Matrix homeserver as instructed in the 148 | relevant homeserver documentation. 149 | -------------------------------------------------------------------------------- /federation/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from types import ModuleType 3 | from typing import Union, TYPE_CHECKING 4 | 5 | from federation.exceptions import NoSuitableProtocolFoundError 6 | 7 | if TYPE_CHECKING: 8 | from federation.types import RequestType 9 | 10 | __version__ = "0.26.0" 11 | 12 | PROTOCOLS = ( 13 | "activitypub", 14 | "diaspora", 15 | "matrix", 16 | ) 17 | 18 | 19 | def identify_protocol(method, value): 20 | # type: (str, Union[str, RequestType]) -> ModuleType 21 | """ 22 | Loop through protocols, import the protocol module and try to identify the id or request. 23 | """ 24 | for protocol_name in PROTOCOLS: 25 | protocol = importlib.import_module(f"federation.protocols.{protocol_name}.protocol") 26 | if getattr(protocol, f"identify_{method}")(value): 27 | return protocol 28 | else: 29 | raise NoSuitableProtocolFoundError() 30 | 31 | 32 | def identify_protocol_by_id(identifier: str) -> ModuleType: 33 | return identify_protocol('id', identifier) 34 | 35 | 36 | def identify_protocol_by_request(request): 37 | # type: (RequestType) -> ModuleType 38 | return identify_protocol('request', request) 39 | -------------------------------------------------------------------------------- /federation/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/django/__init__.py -------------------------------------------------------------------------------- /federation/django/urls.py: -------------------------------------------------------------------------------- 1 | # noinspection PyPackageRequirements 2 | from django.conf.urls import url 3 | # noinspection PyPackageRequirements 4 | from django.urls import include 5 | 6 | urlpatterns = [ 7 | url(r'', include("federation.hostmeta.django.urls")), 8 | url(r'ap/', include("federation.entities.activitypub.django.urls")), 9 | url(r'^matrix/', include("federation.entities.matrix.django.urls")), 10 | ] 11 | -------------------------------------------------------------------------------- /federation/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/entities/__init__.py -------------------------------------------------------------------------------- /federation/entities/activitypub/__init__.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import timedelta 3 | from pyld import jsonld 4 | 5 | try: 6 | from federation.utils.django import get_redis 7 | cache = get_redis() or {} 8 | EXPIRATION = int(timedelta(weeks=4).total_seconds()) 9 | except: 10 | cache = {} 11 | 12 | 13 | # This is required to workaround a bug in pyld that has the Accept header 14 | # accept other content types. From what I understand, precedence handling 15 | # is broken 16 | # from https://github.com/digitalbazaar/pyld/issues/133 17 | # cacheing loosely inspired by https://github.com/digitalbazaar/pyld/issues/70 18 | def get_loader(*args, **kwargs): 19 | requests_loader = jsonld.requests_document_loader(*args, **kwargs) 20 | 21 | def loader(url, options={}): 22 | key = f'ld_cache:{url}' 23 | try: 24 | return json.loads(cache[key]) 25 | except KeyError: 26 | options['headers']['Accept'] = 'application/ld+json' 27 | doc = requests_loader(url, options) 28 | if isinstance(cache, dict): 29 | cache[key] = json.dumps(doc) 30 | else: 31 | cache.set(key, json.dumps(doc), ex=EXPIRATION) 32 | return doc 33 | 34 | return loader 35 | 36 | 37 | jsonld.set_document_loader(get_loader()) 38 | -------------------------------------------------------------------------------- /federation/entities/activitypub/constants.py: -------------------------------------------------------------------------------- 1 | CONTEXT_ACTIVITYSTREAMS = "https://www.w3.org/ns/activitystreams" 2 | CONTEXT_SECURITY = "https://w3id.org/security/v1" 3 | 4 | NAMESPACE_PUBLIC = "https://www.w3.org/ns/activitystreams#Public" 5 | -------------------------------------------------------------------------------- /federation/entities/activitypub/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/entities/activitypub/django/__init__.py -------------------------------------------------------------------------------- /federation/entities/activitypub/django/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | 3 | 4 | urlpatterns = [ 5 | ] 6 | -------------------------------------------------------------------------------- /federation/entities/activitypub/django/views.py: -------------------------------------------------------------------------------- 1 | from cryptography.exceptions import InvalidSignature 2 | from django.http import JsonResponse, HttpResponse, HttpResponseNotFound 3 | 4 | from federation.entities.activitypub.mappers import get_outbound_entity 5 | from federation.protocols.activitypub.protocol import Protocol 6 | from federation.types import RequestType 7 | from federation.utils.django import get_function_from_config 8 | 9 | 10 | def get_and_verify_signer(request): 11 | """ 12 | A remote user might be allowed to access retricted content 13 | if a valid signature is provided. 14 | 15 | Only done for content. 16 | """ 17 | # TODO: revisit this when we start responding to sending follow[ing,ers] collections 18 | if request.path.startswith('/u/'): return None 19 | get_public_key = get_function_from_config('get_public_key_function') 20 | if not request.headers.get('Signature'): return None 21 | req = RequestType( 22 | url=request.build_absolute_uri(), 23 | body=request.body, 24 | method=request.method, 25 | headers=request.headers) 26 | protocol = Protocol(request=req, get_contact_key=get_public_key) 27 | try: 28 | protocol.verify() 29 | return protocol.sender 30 | except (ValueError, KeyError, InvalidSignature) as exc: 31 | return None 32 | 33 | 34 | def activitypub_object_view(func): 35 | """ 36 | Generic ActivityPub object view decorator. 37 | 38 | Takes an ID and fetches it using the provided function. Renders the ActivityPub object 39 | in JSON if the object is found. Falls back to decorated view, if the content 40 | type doesn't match. 41 | """ 42 | 43 | def inner(request, *args, **kwargs): 44 | 45 | def get(request, *args, **kwargs): 46 | fallback = True 47 | accept = request.META.get('HTTP_ACCEPT', '') 48 | for content_type in ( 49 | 'application/json', 'application/activity+json', 'application/ld+json', 50 | ): 51 | if accept.find(content_type) > -1: 52 | fallback = False 53 | break 54 | if fallback: 55 | return func(request, *args, **kwargs) 56 | 57 | get_object_function = get_function_from_config('get_object_function') 58 | obj = get_object_function(request, get_and_verify_signer(request)) 59 | if not obj: 60 | return HttpResponseNotFound() 61 | 62 | as2_obj = get_outbound_entity(obj, None) 63 | return JsonResponse(as2_obj.to_as2(), content_type='application/activity+json') 64 | 65 | def post(request, *args, **kwargs): 66 | process_payload_function = get_function_from_config('process_payload_function') 67 | result = process_payload_function(request) 68 | if result: 69 | return JsonResponse({}, content_type='application/json', status=202) 70 | else: 71 | return JsonResponse({"result": "error"}, content_type='application/json', status=400) 72 | 73 | if request.method == 'GET': 74 | return get(request, *args, **kwargs) 75 | elif request.method == 'POST' and request.path.startswith('/u/') and request.path.endswith('/inbox/'): 76 | return post(request, *args, **kwargs) 77 | 78 | return HttpResponse(status=405) 79 | return inner 80 | -------------------------------------------------------------------------------- /federation/entities/activitypub/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class EnumBase(Enum): 5 | @classmethod 6 | def values(cls): 7 | return [value.value for value in cls.__members__.values()] 8 | 9 | 10 | class ActivityType(EnumBase): 11 | ACCEPT = "Accept" 12 | ANNOUNCE = "Announce" 13 | CREATE = "Create" 14 | DELETE = "Delete" 15 | FOLLOW = "Follow" 16 | UNDO = "Undo" 17 | UPDATE = "Update" 18 | 19 | 20 | class ActorType(EnumBase): 21 | PERSON = "Person" 22 | 23 | 24 | class ObjectType(EnumBase): 25 | IMAGE = "Image" 26 | NOTE = "Note" 27 | TOMBSTONE = "Tombstone" 28 | -------------------------------------------------------------------------------- /federation/entities/activitypub/ldcontext.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | 4 | from marshmallow import missing 5 | from pyld import jsonld 6 | 7 | from federation.entities.activitypub.constants import CONTEXT_ACTIVITYSTREAMS, CONTEXT_SECURITY, NAMESPACE_PUBLIC 8 | 9 | 10 | # Extract context information from the metadata parameter defined for fields 11 | # that are not part of the official AP spec. Use the same extended context for 12 | # inbound payload. For outbound payload, build a context with only the required 13 | # extensions 14 | class LdContextManager: 15 | _named = [CONTEXT_ACTIVITYSTREAMS, CONTEXT_SECURITY] 16 | _extensions = {} 17 | _merged = [] 18 | _models = [] 19 | 20 | def __init__(self, models): 21 | self._models = models 22 | for klass in models: 23 | self._extensions[klass] = {} 24 | ctx = getattr(klass, 'ctx', []) 25 | if ctx: 26 | self._extensions[klass].update({klass.__name__: ctx}) 27 | for name, value in klass.schema().declared_fields.items(): 28 | ctx = value.metadata.get('ctx') or [] 29 | if ctx: 30 | self._extensions[klass].update({name: ctx}) 31 | merged = {} 32 | for field in self._extensions.values(): 33 | for ctx in field.values(): 34 | self._add_extensions(ctx, self._named, merged) 35 | self._merged = copy.copy(self._named) 36 | self._merged.append(merged) 37 | 38 | def _add_extensions(self, field, named, extensions): 39 | for item in field: 40 | if isinstance(item, str) and item not in named: 41 | named.append(item) 42 | elif isinstance(item, dict): 43 | extensions.update(item) 44 | 45 | def _get_fields(self, obj): 46 | for klass in self._extensions.keys(): 47 | if issubclass(type(obj), klass): 48 | return self._extensions[klass] 49 | return {} 50 | 51 | def compact(self, obj): 52 | payload = jsonld.compact(obj.dump(), self.build_context(obj)) 53 | patched = copy.copy(payload) 54 | 55 | # This is for platforms that don't handle the single element array 56 | # compaction to a single value and https://www.w3.org/ns/activitystreams#Public 57 | # being compacted to as:Public 58 | def patch_payload(payload, patched): 59 | for field in ('attachment', 'cc', 'tag', 'to'): 60 | value = payload.get(field) 61 | if not value: 62 | continue 63 | if not isinstance(value, list): 64 | value = [value] 65 | patched[field] = value 66 | if field in ('cc', 'to'): 67 | try: 68 | idx = value.index('as:Public') 69 | patched[field][idx] = value[idx].replace('as:Public', NAMESPACE_PUBLIC) 70 | except ValueError: 71 | pass 72 | if isinstance(payload.get('object'), dict): 73 | patch_payload(payload['object'], patched['object']) 74 | 75 | patch_payload(payload, patched) 76 | return patched 77 | 78 | def build_context(self, obj): 79 | from federation.entities.activitypub.models import Object, Link 80 | 81 | final = [CONTEXT_ACTIVITYSTREAMS] 82 | extensions = {} 83 | 84 | def walk_object(obj): 85 | if type(obj) in self._extensions.keys(): 86 | self._add_extensions(self._extensions[type(obj)].get(type(obj).__name__, []), final, extensions) 87 | to_add = self._get_fields(obj) 88 | for field in type(obj).schema().declared_fields.keys(): 89 | field_value = getattr(obj, field) 90 | if field in to_add.keys(): 91 | if field_value is not missing or obj.signable and field == 'signature': 92 | self._add_extensions(to_add[field], final, extensions) 93 | if not isinstance(field_value, list): 94 | field_value = [field_value] 95 | for value in field_value: 96 | if issubclass(type(value), (Object, Link)): 97 | walk_object(value) 98 | 99 | walk_object(obj) 100 | if extensions: 101 | final.append(extensions) 102 | # compact the array if len == 1 to minimize test changes 103 | return final if len(final) > 1 else final[0] 104 | 105 | def merge_context(self, ctx): 106 | # One platform sends a single string context 107 | if isinstance(ctx, str): 108 | ctx = [ctx] 109 | 110 | # add a # at the end of the python-federation string 111 | # for legacy socialhome payloads 112 | s = json.dumps(ctx) 113 | if 'python-federation"' in s: 114 | ctx = json.loads(s.replace('python-federation', 'python-federation#', 1)) 115 | 116 | # Some platforms have reference invalid json-ld document in @context. 117 | # Remove those. 118 | for url in ['http://joinmastodon.org/ns', 'http://schema.org']: 119 | try: 120 | ctx.pop(ctx.index(url)) 121 | except ValueError: 122 | pass 123 | 124 | # remove @language in context since this directive is not 125 | # processed by calamus. Pleroma adds a useless @language: 'und' 126 | # which is discouraged in best practices and in some cases makes 127 | # calamus return dict where str is expected. 128 | # see https://www.rfc-editor.org/rfc/rfc5646, page 56 129 | idx = [] 130 | for i, v in enumerate(ctx): 131 | if isinstance(v, dict): 132 | v.pop('@language', None) 133 | if len(v) == 0: 134 | idx.insert(0, i) 135 | for i in idx: 136 | ctx.pop(i) 137 | 138 | # Merge all defined AP extensions to the inbound context 139 | uris = [] 140 | defs = {} 141 | # Merge original context dicts in one dict, taking into account nested @context 142 | def parse_context(ctx): 143 | for item in ctx: 144 | if isinstance(item, list): 145 | parse_context(item) 146 | elif isinstance(item, str): 147 | uris.append(item) 148 | else: 149 | if '@context' in item: 150 | parse_context([item['@context']]) 151 | item.pop('@context') 152 | defs.update(item) 153 | parse_context(ctx) 154 | 155 | for item in self._merged: 156 | if isinstance(item, str) and item not in uris: 157 | uris.append(item) 158 | elif isinstance(item, dict): 159 | defs.update(item) 160 | 161 | final = copy.copy(uris) 162 | final.append(defs) 163 | return final 164 | -------------------------------------------------------------------------------- /federation/entities/activitypub/ldsigning.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import math 4 | import re 5 | from base64 import b64encode, b64decode 6 | from copy import copy 7 | from funcy import omit 8 | from pyld import jsonld 9 | 10 | from Crypto.Hash import SHA256 11 | from Crypto.PublicKey.RSA import import_key 12 | from Crypto.Signature import pkcs1_15 13 | 14 | from federation.entities.utils import get_profile 15 | from federation.utils.activitypub import retrieve_and_parse_document 16 | 17 | 18 | logger = logging.getLogger("federation") 19 | 20 | 21 | def create_ld_signature(obj, author): 22 | # Use models.Signature? Maybe overkill... 23 | sig = { 24 | 'created': datetime.datetime.now(tz=datetime.timezone.utc).isoformat(timespec='seconds'), 25 | 'creator': f'{author.id}#main-key', 26 | '@context': 'https://w3id.org/security/v1' 27 | } 28 | 29 | try: 30 | private_key = import_key(author.private_key) 31 | except (ValueError, TypeError) as exc: 32 | logger.warning('ld_signature - %s', exc) 33 | return None 34 | signer = pkcs1_15.new(private_key) 35 | 36 | sig_digest = hash(sig) 37 | obj_digest = hash(obj) 38 | digest = (sig_digest + obj_digest).encode('utf-8') 39 | 40 | signature = signer.sign(SHA256.new(digest)) 41 | sig.update({'type': 'RsaSignature2017', 'signatureValue': b64encode(signature).decode()}) 42 | sig.pop('@context') 43 | 44 | obj.update({'signature': sig}) 45 | 46 | 47 | def verify_ld_signature(payload): 48 | """ 49 | Verify inbound payload LD signature 50 | """ 51 | signature = copy(payload.get('signature', None)) 52 | if not signature: 53 | logger.warning('ld_signature - No signature in %s', payload.get("id", "the payload")) 54 | return None 55 | 56 | # retrieve the author's public key 57 | profile = get_profile(key_id=signature.get('creator')) 58 | if not profile: 59 | profile = retrieve_and_parse_document(signature.get('creator')) 60 | if not profile: 61 | logger.warning('ld_signature - Failed to retrieve profile for %s', signature.get("creator")) 62 | return None 63 | try: 64 | pkey = import_key(profile.public_key) 65 | except ValueError as exc: 66 | logger.warning('ld_signature - %s', exc) 67 | return None 68 | verifier = pkcs1_15.new(pkey) 69 | 70 | # Compute digests and verify signature 71 | sig = omit(signature, ('type', 'signatureValue')) 72 | sig.update({'@context': 'https://w3id.org/security/v1'}) 73 | sig_digest = hash(sig) 74 | obj = omit(payload, 'signature') 75 | obj_digest = hash(obj) 76 | digest = (sig_digest + obj_digest).encode('utf-8') 77 | 78 | try: 79 | sig_value = b64decode(signature.get('signatureValue')) 80 | verifier.verify(SHA256.new(digest), sig_value) 81 | logger.debug('ld_signature - %s has a valid signature', payload.get("id")) 82 | return profile.id 83 | except ValueError: 84 | logger.warning('ld_signature - Invalid signature for %s', payload.get("id")) 85 | return None 86 | 87 | 88 | def hash(obj): 89 | nquads = NormalizedDoubles().normalize(obj, options={'format': 'application/nquads', 'algorithm': 'URDNA2015'}) 90 | return SHA256.new(nquads.encode('utf-8')).hexdigest() 91 | 92 | 93 | # We need this to ensure the digests are identical. 94 | class NormalizedDoubles(jsonld.JsonLdProcessor): 95 | def _object_to_rdf(self, item, issuer, triples, rdfDirection): 96 | value = item['@value'] if jsonld._is_value(item) else None 97 | # The ruby rdf_normalize library turns floats with a zero fraction into integers. 98 | if isinstance(value, float) and value == math.floor(value): 99 | item['@value'] = math.floor(value) 100 | obj = super()._object_to_rdf(item, issuer, triples, rdfDirection) 101 | # This is to address https://github.com/digitalbazaar/pyld/issues/175 102 | if obj and obj.get('datatype') == jsonld.XSD_DOUBLE: 103 | obj['value'] = re.sub(r'(\d)0*E\+?(-)?0*(\d)', r'\1E\2\3', obj['value']) 104 | return obj 105 | -------------------------------------------------------------------------------- /federation/entities/activitypub/mappers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Callable, Dict, Union, Optional 3 | 4 | from federation.entities.activitypub.models import element_to_objects 5 | from federation.entities.base import Follow, Profile, Accept, Post, Comment, Retraction, Share, Image, Collection 6 | from federation.entities.mixins import BaseEntity 7 | from federation.types import UserType, ReceiverVariant 8 | import federation.entities.activitypub.models as models 9 | 10 | logger = logging.getLogger("federation") 11 | 12 | 13 | def get_outbound_entity(entity: BaseEntity, private_key): 14 | """Get the correct outbound entity for this protocol. 15 | 16 | We might have to look at entity values to decide the correct outbound entity. 17 | If we cannot find one, we should raise as conversion cannot be guaranteed to the given protocol. 18 | 19 | Private key of author is needed to be passed for signing the outbound entity. 20 | 21 | :arg entity: An entity instance which can be of a base or protocol entity class. 22 | :arg private_key: Private key of sender in str format 23 | :returns: Protocol specific entity class instance. 24 | :raises ValueError: If conversion cannot be done. 25 | """ 26 | if getattr(entity, "outbound_doc", None): 27 | # If the entity already has an outbound doc, just return the entity as is 28 | return entity 29 | outbound = None 30 | cls = entity.__class__ 31 | if cls in [ 32 | models.Accept, models.Follow, models.Person, models.Note, 33 | models.Delete, models.Tombstone, models.Announce, models.Collection, 34 | models.OrderedCollection, 35 | ] and isinstance(entity, BaseEntity): 36 | # Already fine 37 | outbound = entity 38 | elif cls == Accept: 39 | outbound = models.Accept.from_base(entity) 40 | elif cls == Follow: 41 | outbound = models.Follow.from_base(entity) 42 | elif cls == Post: 43 | outbound = models.Post.from_base(entity) 44 | elif cls == Comment: 45 | outbound = models.Comment.from_base(entity) 46 | elif cls == Profile: 47 | outbound = models.Person.from_base(entity) 48 | elif cls == Retraction: 49 | if entity.entity_type in ('Post', 'Comment'): 50 | outbound = models.Tombstone.from_base(entity) 51 | outbound.activity = models.Delete 52 | elif entity.entity_type == 'Share': 53 | outbound = models.Announce.from_base(entity) 54 | outbound.activity = models.Undo 55 | outbound._required.remove('id') 56 | elif entity.entity_type == 'Profile': 57 | outbound = models.Delete.from_base(entity) 58 | elif cls == Share: 59 | outbound = models.Announce.from_base(entity) 60 | elif cls == Collection: 61 | outbound = models.OrderedCollection.from_base(entity) if entity.ordered else models.Collection.from_base(entity) 62 | if not outbound: 63 | raise ValueError("Don't know how to convert this base entity to ActivityPub protocol entities.") 64 | # TODO LDS signing 65 | # if isinstance(outbound, DiasporaRelayableMixin) and not outbound.signature: 66 | # # Sign by author if not signed yet. We don't want to overwrite any existing signature in the case 67 | # # that this is being sent by the parent author 68 | # outbound.sign(private_key) 69 | # # If missing, also add same signature to `parent_author_signature`. This is required at the moment 70 | # # in all situations but is apparently being removed. 71 | # # TODO: remove this once Diaspora removes the extra signature 72 | # outbound.parent_signature = outbound.signature 73 | if hasattr(outbound, "pre_send"): 74 | outbound.pre_send() 75 | # Validate the entity 76 | outbound.validate(direction="outbound") 77 | return outbound 78 | 79 | 80 | def message_to_objects( 81 | message: Dict, sender: str = "", sender_key_fetcher: Callable[[str], str] = None, user: UserType = None, 82 | ) -> List: 83 | """ 84 | Takes in a message extracted by a protocol and maps it to entities. 85 | """ 86 | # We only really expect one element here for ActivityPub. 87 | return element_to_objects(message, sender) 88 | 89 | 90 | -------------------------------------------------------------------------------- /federation/entities/base.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Tuple 2 | from magic import from_file 3 | 4 | from dirty_validators.basic import Email 5 | 6 | from federation.entities.activitypub.enums import ActivityType 7 | from federation.entities.mixins import ( 8 | PublicMixin, TargetIDMixin, ParticipationMixin, CreatedAtMixin, RawContentMixin, OptionalRawContentMixin, 9 | EntityTypeMixin, ProviderDisplayNameMixin, RootTargetIDMixin, MediaMixin, BaseEntity) 10 | from federation.utils.network import fetch_file 11 | 12 | 13 | class Accept(CreatedAtMixin, TargetIDMixin, BaseEntity): 14 | """An acceptance message for some target.""" 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | # ID not required for accept 19 | self._required.remove('id') 20 | 21 | 22 | class Image(MediaMixin, OptionalRawContentMixin, CreatedAtMixin, BaseEntity): 23 | """Reflects a single image, possibly linked to another object.""" 24 | height: int = 0 25 | width: int = 0 26 | name: str = "" 27 | inline: bool = False 28 | 29 | _valid_media_types: Tuple[str] = ( 30 | "image/jpeg", 31 | "image/png", 32 | "image/gif", 33 | ) 34 | 35 | def get_media_type(self) -> str: 36 | media_type = super().get_media_type() 37 | if media_type == 'application/octet-stream': 38 | try: 39 | file = fetch_file(self.url) 40 | media_type = from_file(file, mime=True) 41 | os.unlink(file) 42 | except: 43 | pass 44 | return media_type 45 | 46 | 47 | class Audio(MediaMixin, OptionalRawContentMixin, BaseEntity): 48 | inlineMedia: bool = False 49 | 50 | _valid_media_types: Tuple[str] = ( 51 | "audio/aac", 52 | "audio/mpeg", 53 | "audio/ogg", 54 | "audio/wav", 55 | "audio/webm" 56 | ) 57 | 58 | class Video(MediaMixin, OptionalRawContentMixin, BaseEntity): 59 | inlineMedia: bool = False 60 | 61 | _valid_media_types: Tuple[str] = ( 62 | "video/mp4", 63 | "video/mpeg", 64 | "video/ogg", 65 | "video/x-msvideo", 66 | "video/webm", 67 | ) 68 | 69 | 70 | class Comment(RawContentMixin, ParticipationMixin, CreatedAtMixin, RootTargetIDMixin, BaseEntity): 71 | """Represents a comment, linked to another object.""" 72 | participation = "comment" 73 | url = "" 74 | 75 | _allowed_children = (Image,) 76 | _default_activity = ActivityType.CREATE 77 | 78 | 79 | class Follow(CreatedAtMixin, TargetIDMixin, BaseEntity): 80 | """Represents a handle following or unfollowing another handle.""" 81 | following = True 82 | 83 | def __init__(self, *args, **kwargs): 84 | super().__init__(*args, **kwargs) 85 | self._required += ["following"] 86 | # ID not required for follow 87 | self._required.remove('id') 88 | 89 | 90 | class Post(RawContentMixin, PublicMixin, CreatedAtMixin, ProviderDisplayNameMixin, BaseEntity): 91 | """Reflects a post, status message, etc, which will be composed from the message or to the message.""" 92 | location = "" 93 | url = "" 94 | 95 | _allowed_children = (Image,) 96 | _default_activity = ActivityType.CREATE 97 | 98 | 99 | class Reaction(ParticipationMixin, CreatedAtMixin, BaseEntity): 100 | """Represents a reaction to another object, for example a like.""" 101 | participation = "reaction" 102 | reaction = "" 103 | 104 | _default_activity = ActivityType.CREATE 105 | _reaction_valid_values = ["like"] 106 | 107 | def __init__(self, *args, **kwargs): 108 | super().__init__(*args, **kwargs) 109 | self._required += ["reaction"] 110 | 111 | def validate_reaction(self): 112 | """Ensure reaction is of a certain type. 113 | 114 | Mainly for future expansion. 115 | """ 116 | if self.reaction not in self._reaction_valid_values: 117 | raise ValueError("reaction should be one of: {valid}".format( 118 | valid=", ".join(self._reaction_valid_values) 119 | )) 120 | 121 | 122 | class Relationship(CreatedAtMixin, TargetIDMixin, BaseEntity): 123 | """Represents a relationship between two handles.""" 124 | relationship = "" 125 | 126 | _relationship_valid_values = ["sharing", "following", "ignoring", "blocking"] 127 | 128 | def __init__(self, *args, **kwargs): 129 | super().__init__(*args, **kwargs) 130 | self._required += ["relationship"] 131 | 132 | def validate_relationship(self): 133 | """Ensure relationship is of a certain type.""" 134 | if self.relationship not in self._relationship_valid_values: 135 | raise ValueError("relationship should be one of: {valid}".format( 136 | valid=", ".join(self._relationship_valid_values) 137 | )) 138 | 139 | 140 | class Profile(CreatedAtMixin, OptionalRawContentMixin, PublicMixin, BaseEntity): 141 | """Represents a profile for a user.""" 142 | atom_url = "" 143 | email = "" 144 | gender = "" 145 | image_urls = None 146 | image = None 147 | location = "" 148 | name = "" 149 | nsfw = False 150 | public_key = "" 151 | tag_list = None 152 | url = "" 153 | username = "" 154 | inboxes: Dict = None 155 | 156 | _allowed_children = (Image,) 157 | 158 | def __init__(self, *args, **kwargs): 159 | self.image_urls = { 160 | "small": "", "medium": "", "large": "" 161 | } 162 | self.inboxes = { 163 | "private": None, 164 | "public": None, 165 | } 166 | self.tag_list = [] 167 | super().__init__(*args, **kwargs) 168 | # As an exception, a Profile does not require to have an `actor_id` 169 | self._required.remove('actor_id') 170 | 171 | def validate_email(self): 172 | if self.email: 173 | validator = Email() 174 | if not validator.is_valid(self.email): 175 | raise ValueError("Email is not valid") 176 | 177 | 178 | class Retraction(CreatedAtMixin, TargetIDMixin, EntityTypeMixin, BaseEntity): 179 | """Represents a retraction of content by author.""" 180 | 181 | def __init__(self, *args, **kwargs): 182 | super().__init__(*args, **kwargs) 183 | # ID not required for retraction 184 | self._required.remove('id') 185 | 186 | 187 | class Share(CreatedAtMixin, TargetIDMixin, EntityTypeMixin, OptionalRawContentMixin, PublicMixin, 188 | ProviderDisplayNameMixin, BaseEntity): 189 | """Represents a share of another entity. 190 | 191 | ``entity_type`` defaults to "Post" but can be any base entity class name. It should be the class name of the 192 | entity that was shared. 193 | 194 | The optional ``raw_content`` can be used for a "quoted share" case where the sharer adds their own note to the 195 | share. 196 | """ 197 | entity_type = "Post" 198 | 199 | 200 | class Collection(BaseEntity): 201 | """Represents collections of objects. 202 | 203 | Only useful to Activitypub outbound payloads. 204 | """ 205 | ordered = False 206 | total_items = 0 207 | items = [] 208 | 209 | def __init__(self, *args, **kwargs): 210 | super().__init__(*args, **kwargs) 211 | self._required.remove('actor_id') 212 | self._required += ['ordered'] 213 | -------------------------------------------------------------------------------- /federation/entities/diaspora/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/entities/diaspora/__init__.py -------------------------------------------------------------------------------- /federation/entities/diaspora/mixins.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | 4 | from bs4 import BeautifulSoup, Tag 5 | from commonmark import commonmark 6 | from Crypto.PublicKey import RSA 7 | from lxml import etree 8 | from markdownify import markdownify 9 | 10 | from federation.entities.diaspora.utils import add_element_to_doc 11 | from federation.entities.mixins import BaseEntity 12 | from federation.entities.utils import get_base_attributes 13 | from federation.exceptions import SignatureVerificationError 14 | from federation.protocols.diaspora.signatures import verify_relayable_signature, create_relayable_signature 15 | 16 | logger = logging.getLogger("federation") 17 | 18 | class DiasporaEntityMixin(BaseEntity): 19 | # Normally outbound document is generated from entity. Store one here if at some point we already have a doc 20 | outbound_doc = None 21 | 22 | def to_string(self) -> str: 23 | """ 24 | Return string representation of the entity, for debugging mostly. 25 | """ 26 | return etree.tostring(self.to_xml()).decode('utf-8') 27 | 28 | def to_xml(self): 29 | """Override in subclasses.""" 30 | raise NotImplementedError 31 | 32 | @classmethod 33 | def from_base(cls, entity): 34 | return cls(**get_base_attributes(entity)) 35 | 36 | @staticmethod 37 | def fill_extra_attributes(attributes): 38 | """Implement in subclasses to fill extra attributes when an XML is transformed to an object. 39 | 40 | This is called just before initializing the entity. 41 | 42 | Args: 43 | attributes (dict) - Already transformed attributes that will be passed to entity create. 44 | 45 | Returns: 46 | Must return the attributes dictionary, possibly with changed or additional values. 47 | """ 48 | return attributes 49 | 50 | 51 | class DiasporaPreSendMixin: 52 | def pre_send(self): 53 | # replace media tags with a link to their source since 54 | # Diaspora instances are likely to filter them out. 55 | # use the client provided rendered content as source 56 | try: 57 | soup = BeautifulSoup(commonmark(self.raw_content, ignore_html_blocks=True), 'html.parser') 58 | for source in soup.find_all('source', src=re.compile(r'^http')): 59 | link = Tag(name='a', attrs={'href': source['src']}) 60 | link.string = "{} link: {}".format(source.parent.name, source['src'].split('/')[-1]) 61 | source.parent.replace_with(link) 62 | self.raw_content = markdownify(str(soup)) 63 | except: 64 | logger.warning("failed to replace media tags for Diaspora payload.") 65 | 66 | # add curly braces to mentions 67 | if hasattr(self, 'extract_mentions'): 68 | self.extract_mentions() 69 | for mention in self._mentions: 70 | self.raw_content = self.raw_content.replace('@'+mention, '@{'+mention+'}') 71 | 72 | 73 | 74 | 75 | class DiasporaRelayableMixin(DiasporaEntityMixin): 76 | _xml_tags = [] 77 | parent_signature = "" 78 | 79 | def __init__(self, *args, **kwargs): 80 | super().__init__(*args, **kwargs) 81 | self._required += ["signature"] 82 | 83 | def _validate_signatures(self): 84 | super()._validate_signatures() 85 | if not self._sender_key: 86 | raise SignatureVerificationError("Cannot verify entity signature - no sender key available") 87 | source_doc = etree.fromstring(self._source_object) 88 | if not verify_relayable_signature(self._sender_key, source_doc, self.signature): 89 | raise SignatureVerificationError("Signature verification failed.") 90 | 91 | def sign(self, private_key: RSA) -> None: 92 | self.signature = create_relayable_signature(private_key, self.to_xml()) 93 | 94 | def sign_with_parent(self, private_key): 95 | if self._source_object: 96 | doc = etree.fromstring(self._source_object) 97 | else: 98 | doc = self.to_xml() 99 | self.parent_signature = create_relayable_signature(private_key, doc) 100 | add_element_to_doc(doc, "parent_author_signature", self.parent_signature) 101 | self.outbound_doc = doc 102 | -------------------------------------------------------------------------------- /federation/entities/diaspora/utils.py: -------------------------------------------------------------------------------- 1 | from dateutil.tz import tzlocal, tzutc 2 | from lxml import etree 3 | 4 | 5 | def ensure_timezone(dt, tz=None): 6 | """ 7 | Make sure the datetime
has a timezone set, using timezone if it 8 | doesn't. defaults to the local timezone. 9 | """ 10 | if dt.tzinfo is None: 11 | return dt.replace(tzinfo=tz or tzlocal()) 12 | else: 13 | return dt 14 | 15 | 16 | def format_dt(dt): 17 | """ 18 | Format a datetime in the way that D* nodes expect. 19 | """ 20 | return ensure_timezone(dt).astimezone(tzutc()).strftime( 21 | '%Y-%m-%dT%H:%M:%SZ' 22 | ) 23 | 24 | 25 | def struct_to_xml(node, struct): 26 | """ 27 | Turn a list of dicts into XML nodes with tag names taken from the dict 28 | keys and element text taken from dict values. This is a list of dicts 29 | so that the XML nodes can be ordered in the XML output. 30 | """ 31 | for obj in struct: 32 | for k, v in obj.items(): 33 | etree.SubElement(node, k).text = v 34 | 35 | 36 | def get_full_xml_representation(entity, private_key): 37 | """Get full XML representation of an entity. 38 | 39 | This contains the .. wrapper. 40 | 41 | Accepts either a Base entity or a Diaspora entity. 42 | 43 | Author `private_key` must be given so that certain entities can be signed. 44 | """ 45 | from federation.entities.diaspora.mappers import get_outbound_entity 46 | diaspora_entity = get_outbound_entity(entity, private_key) 47 | xml = diaspora_entity.to_xml() 48 | return "%s" % etree.tostring(xml).decode("utf-8") 49 | 50 | 51 | def add_element_to_doc(doc, tag, value): 52 | """Set text value of an etree.Element of tag, appending a new element with given tag if it doesn't exist.""" 53 | element = doc.find(".//%s" % tag) 54 | if element is None: 55 | element = etree.SubElement(doc, tag) 56 | element.text = value 57 | -------------------------------------------------------------------------------- /federation/entities/matrix/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/entities/matrix/__init__.py -------------------------------------------------------------------------------- /federation/entities/matrix/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/entities/matrix/django/__init__.py -------------------------------------------------------------------------------- /federation/entities/matrix/django/urls.py: -------------------------------------------------------------------------------- 1 | # noinspection PyPackageRequirements 2 | from django.conf.urls import url 3 | # noinspection PyPackageRequirements 4 | from django.views.decorators.csrf import csrf_exempt 5 | 6 | from federation.entities.matrix.django.views import MatrixASTransactionsView 7 | 8 | urlpatterns = [ 9 | url( 10 | regex=r"transactions/(?P[\w-]+)$", 11 | view=csrf_exempt(MatrixASTransactionsView.as_view()), 12 | name="matrix-as-transactions", 13 | ), 14 | ] 15 | -------------------------------------------------------------------------------- /federation/entities/matrix/django/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # noinspection PyPackageRequirements 4 | from django.http import JsonResponse 5 | # noinspection PyPackageRequirements 6 | from django.views import View 7 | 8 | from federation.utils.django import get_function_from_config 9 | from federation.utils.matrix import get_matrix_configuration 10 | 11 | logger = logging.getLogger("federation") 12 | 13 | 14 | class MatrixASBaseView(View): 15 | def dispatch(self, request, *args, **kwargs): 16 | token = request.GET.get("access_token") 17 | if not token: 18 | return JsonResponse({"error": "M_FORBIDDEN"}, content_type='application/json', status=403) 19 | 20 | matrix_config = get_matrix_configuration() 21 | if token != matrix_config["appservice"]["token"]: 22 | return JsonResponse({"error": "M_FORBIDDEN"}, content_type='application/json', status=403) 23 | 24 | return super().dispatch(request, *args, **kwargs) 25 | 26 | 27 | class MatrixASTransactionsView(MatrixASBaseView): 28 | # noinspection PyUnusedLocal,PyMethodMayBeStatic 29 | def put(self, request, *args, **kwargs): 30 | # Inject the transaction ID to the request as part of the meta items 31 | request.META["matrix_transaction_id"] = kwargs.get("txn_id") 32 | process_payload_function = get_function_from_config('process_payload_function') 33 | result = process_payload_function(request) 34 | 35 | if result: 36 | return JsonResponse({}, content_type='application/json', status=200) 37 | else: 38 | return JsonResponse({"error": "M_UNKNOWN"}, content_type='application/json', status=400) 39 | -------------------------------------------------------------------------------- /federation/entities/matrix/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class EnumBase(Enum): 5 | @classmethod 6 | def values(cls): 7 | return [value.value for value in cls.__members__.values()] 8 | 9 | 10 | class EventType(EnumBase): 11 | ROOM_MESSAGE = "m.room.message" 12 | -------------------------------------------------------------------------------- /federation/entities/matrix/mappers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from federation.entities.base import Profile, Post 4 | from federation.entities.matrix.entities import MatrixRoomMessage, MatrixProfile 5 | from federation.entities.mixins import BaseEntity 6 | 7 | logger = logging.getLogger("federation") 8 | 9 | 10 | def get_outbound_entity(entity: BaseEntity, private_key): 11 | """Get the correct outbound entity for this protocol. 12 | 13 | :arg entity: An entity instance which can be of a base or protocol entity class. 14 | :arg private_key: Private key of sender in str format 15 | :returns: Protocol specific entity class instance. 16 | :raises ValueError: If conversion cannot be done. 17 | """ 18 | if getattr(entity, "outbound_doc", None): 19 | # If the entity already has an outbound doc, just return the entity as is 20 | return entity 21 | outbound = None 22 | cls = entity.__class__ 23 | if cls in [ 24 | MatrixProfile, 25 | MatrixRoomMessage, 26 | ]: 27 | # Already fine 28 | outbound = entity 29 | elif cls == Post: 30 | outbound = MatrixRoomMessage.from_base(entity) 31 | elif cls == Profile: 32 | outbound = MatrixProfile.from_base(entity) 33 | if not outbound: 34 | raise ValueError("Don't know how to convert this base entity to Matrix protocol entities.") 35 | if hasattr(outbound, "pre_send"): 36 | outbound.pre_send() 37 | # Validate the entity 38 | outbound.validate(direction="outbound") 39 | return outbound 40 | -------------------------------------------------------------------------------- /federation/entities/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Optional, TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from federation.entities.base import Profile 6 | 7 | 8 | def get_base_attributes(entity, keep=()): 9 | """Build a dict of attributes of an entity. 10 | 11 | Returns attributes and their values, ignoring any properties, functions and anything that starts 12 | with an underscore. 13 | """ 14 | attributes = {} 15 | cls = entity.__class__ 16 | for attr, _ in inspect.getmembers(cls, lambda o: not isinstance(o, property) and not inspect.isroutine(o)): 17 | if not attr.startswith("_") or attr in keep: 18 | value = getattr(entity, attr) 19 | if value or isinstance(value, bool): 20 | attributes[attr] = value 21 | return attributes 22 | 23 | 24 | def get_name_for_profile(fid: str) -> Optional[str]: 25 | """ 26 | Get a profile display name from a profile via the configured profile getter. 27 | 28 | Currently only works with Django configuration. 29 | """ 30 | try: 31 | from federation.utils.django import get_function_from_config 32 | profile_func = get_function_from_config("get_profile_function") 33 | if not profile_func: 34 | return 35 | profile = profile_func(fid=fid) 36 | if not profile: 37 | return 38 | if profile.name == fid and profile.username: 39 | return profile.username 40 | else: 41 | return profile.name 42 | except Exception: 43 | pass 44 | 45 | 46 | def get_profile(**kwargs): 47 | # type: (str) -> Profile 48 | """ 49 | Get a profile via the configured profile getter. 50 | 51 | Currently only works with Django configuration. 52 | """ 53 | try: 54 | from federation.utils.django import get_function_from_config 55 | profile_func = get_function_from_config("get_profile_function") 56 | if not profile_func: 57 | return 58 | return profile_func(**kwargs) 59 | except Exception: 60 | pass 61 | -------------------------------------------------------------------------------- /federation/exceptions.py: -------------------------------------------------------------------------------- 1 | class EncryptedMessageError(Exception): 2 | """Encrypted message could not be opened.""" 3 | pass 4 | 5 | 6 | class NoSenderKeyFoundError(Exception): 7 | """Sender private key was not available to sign a payload message.""" 8 | pass 9 | 10 | 11 | class NoSuitableProtocolFoundError(Exception): 12 | """No suitable protocol found to pass this payload message to.""" 13 | pass 14 | 15 | 16 | class SignatureVerificationError(Exception): 17 | """Authenticity of the signature could not be verified given the key.""" 18 | pass 19 | -------------------------------------------------------------------------------- /federation/fetchers.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | from typing import Optional, Callable 4 | 5 | from federation import identify_protocol_by_id 6 | from federation.entities.base import Profile 7 | from federation.protocols.activitypub import protocol as activitypub_protocol 8 | from federation.protocols.diaspora import protocol as diaspora_protocol 9 | from federation.utils.text import validate_handle 10 | 11 | logger = logging.getLogger("federation") 12 | 13 | 14 | def retrieve_remote_content( 15 | id: str, guid: str = None, handle: str = None, entity_type: str = None, 16 | sender_key_fetcher: Callable[[str], str] = None, cache: bool=True, 17 | ): 18 | """Retrieve remote content and return an Entity object. 19 | 20 | ``sender_key_fetcher`` is an optional function to use to fetch sender public key. If not given, network will be used 21 | to fetch the profile and the key. Function must take federation id as only parameter and return a public key. 22 | """ 23 | if handle and validate_handle(handle): 24 | protocol_name = "diaspora" 25 | if not guid: 26 | guid = id 27 | else: 28 | protocol_name = identify_protocol_by_id(id).PROTOCOL_NAME 29 | utils = importlib.import_module("federation.utils.%s" % protocol_name) 30 | return utils.retrieve_and_parse_content( 31 | id=id, guid=guid, handle=handle, entity_type=entity_type, 32 | cache=cache, sender_key_fetcher=sender_key_fetcher, 33 | ) 34 | 35 | 36 | def retrieve_remote_profile(id: str) -> Optional[Profile]: 37 | """High level retrieve profile method. 38 | 39 | Retrieve the profile from a remote location, using protocols based on the given ID. 40 | """ 41 | if validate_handle(id): 42 | protocols = (activitypub_protocol, diaspora_protocol) 43 | else: 44 | protocols = (identify_protocol_by_id(id),) 45 | for protocol in protocols: 46 | utils = importlib.import_module(f"federation.utils.{protocol.PROTOCOL_NAME}") 47 | profile = utils.retrieve_and_parse_profile(id) 48 | if profile: 49 | return profile 50 | -------------------------------------------------------------------------------- /federation/hostmeta/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/hostmeta/__init__.py -------------------------------------------------------------------------------- /federation/hostmeta/django/__init__.py: -------------------------------------------------------------------------------- 1 | from .generators import rfc7033_webfinger_view 2 | -------------------------------------------------------------------------------- /federation/hostmeta/django/generators.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | # noinspection PyPackageRequirements 4 | from django.http import HttpResponseBadRequest, JsonResponse, HttpResponseNotFound 5 | 6 | from federation.hostmeta.generators import ( 7 | RFC7033Webfinger, generate_nodeinfo2_document, MatrixClientWellKnown, MatrixServerWellKnown, 8 | ) 9 | from federation.utils.django import get_configuration, get_function_from_config 10 | from federation.utils.text import get_path_from_url 11 | 12 | logger = logging.getLogger("federation") 13 | 14 | 15 | def nodeinfo2_view(request, *args, **kwargs): 16 | try: 17 | nodeinfo2_func = get_function_from_config("nodeinfo2_function") 18 | except AttributeError: 19 | return HttpResponseBadRequest("Not configured") 20 | nodeinfo2 = nodeinfo2_func() 21 | 22 | return JsonResponse(generate_nodeinfo2_document(**nodeinfo2)) 23 | 24 | 25 | def matrix_client_wellknown_view(request, *args, **kwargs): 26 | try: 27 | matrix_config_func = get_function_from_config("matrix_config_function") 28 | except AttributeError: 29 | return HttpResponseBadRequest("Not configured") 30 | matrix_config = matrix_config_func() 31 | 32 | wellknown = MatrixClientWellKnown( 33 | homeserver_base_url=matrix_config["homeserver_base_url"], 34 | identity_server_base_url=matrix_config.get("identity_server_base_url"), 35 | other_keys=matrix_config.get("client_wellknown_other_keys"), 36 | ) 37 | return JsonResponse(wellknown.render()) 38 | 39 | 40 | def matrix_server_wellknown_view(request, *args, **kwargs): 41 | try: 42 | matrix_config_func = get_function_from_config("matrix_config_function") 43 | except AttributeError: 44 | return HttpResponseBadRequest("Not configured") 45 | matrix_config = matrix_config_func() 46 | 47 | wellknown = MatrixServerWellKnown( 48 | homeserver_domain_with_port=matrix_config["homeserver_domain_with_port"], 49 | ) 50 | return JsonResponse(wellknown.render()) 51 | 52 | 53 | def rfc7033_webfinger_view(request, *args, **kwargs): 54 | """ 55 | Django view to generate an RFC7033 webfinger. 56 | """ 57 | resource = request.GET.get("resource") 58 | kwargs = {} 59 | if not resource: 60 | return HttpResponseBadRequest("No resource found") 61 | if resource.startswith("acct:"): 62 | kwargs['handle'] = resource.replace("acct:", "").lower() 63 | elif resource.startswith("http"): 64 | kwargs['fid'] = resource 65 | else: 66 | return HttpResponseBadRequest("Invalid resource") 67 | kwargs['request'] = request 68 | logger.debug("%s requested with %s", kwargs, resource) 69 | profile_func = get_function_from_config("get_profile_function") 70 | 71 | try: 72 | profile = profile_func(**kwargs) 73 | except Exception as exc: 74 | logger.warning("rfc7033_webfinger_view - Failed to get profile from resource %s: %s", resource, exc) 75 | return HttpResponseNotFound() 76 | 77 | config = get_configuration() 78 | webfinger = RFC7033Webfinger( 79 | id=profile.id, 80 | handle=profile.handle, 81 | guid=profile.guid, 82 | base_url=config.get('base_url'), 83 | profile_path=get_path_from_url(profile.url), 84 | hcard_path=config.get('hcard_path'), 85 | atom_path=get_path_from_url(profile.atom_url), 86 | search_path=config.get('search_path'), 87 | ) 88 | 89 | return JsonResponse( 90 | webfinger.render(), 91 | content_type="application/jrd+json", 92 | ) 93 | -------------------------------------------------------------------------------- /federation/hostmeta/django/urls.py: -------------------------------------------------------------------------------- 1 | # noinspection PyPackageRequirements 2 | from django.conf.urls import url 3 | 4 | from federation.hostmeta.django import rfc7033_webfinger_view 5 | from federation.hostmeta.django.generators import ( 6 | nodeinfo2_view, matrix_client_wellknown_view, matrix_server_wellknown_view, 7 | ) 8 | 9 | urlpatterns = [ 10 | url(r'^.well-known/matrix/client$', matrix_client_wellknown_view, name="matrix-client-wellknown"), 11 | url(r'^.well-known/matrix/server$', matrix_server_wellknown_view, name="matrix-server-wellknown"), 12 | url(r'^.well-known/webfinger$', rfc7033_webfinger_view, name="rfc7033-webfinger"), 13 | url(r'^.well-known/x-nodeinfo2$', nodeinfo2_view, name="nodeinfo2"), 14 | ] 15 | -------------------------------------------------------------------------------- /federation/hostmeta/fetchers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Dict, Optional 3 | 4 | import requests 5 | 6 | from federation.hostmeta.parsers import ( 7 | parse_nodeinfo_document, parse_nodeinfo2_document, parse_statisticsjson_document, parse_mastodon_document, 8 | parse_matrix_document, parse_misskey_document) 9 | from federation.utils.network import fetch_document 10 | 11 | HIGHEST_SUPPORTED_NODEINFO_VERSION = 2.1 12 | 13 | 14 | def fetch_mastodon_document(host): 15 | doc, status_code, error = fetch_document(host=host, path='/api/v1/instance') 16 | if not doc: 17 | return 18 | try: 19 | doc = json.loads(doc) 20 | except json.JSONDecodeError: 21 | return 22 | return parse_mastodon_document(doc, host) 23 | 24 | 25 | def fetch_matrix_document(host: str) -> Optional[Dict]: 26 | doc, status_code, error = fetch_document(host=host, path='/_matrix/federation/v1/version') 27 | if not doc: 28 | return 29 | try: 30 | doc = json.loads(doc) 31 | except json.JSONDecodeError: 32 | return 33 | return parse_matrix_document(doc, host) 34 | 35 | 36 | def fetch_misskey_document(host: str, mastodon_document: Dict=None) -> Optional[Dict]: 37 | try: 38 | response = requests.post(f'https://{host}/api/meta') # ¯\_(ツ)_/¯ 39 | except Exception: 40 | return 41 | try: 42 | doc = response.json() 43 | except json.JSONDecodeError: 44 | return 45 | if response.status_code == 200: 46 | return parse_misskey_document(doc, host, mastodon_document=mastodon_document) 47 | 48 | 49 | def fetch_nodeinfo_document(host): 50 | doc, status_code, error = fetch_document(host=host, path='/.well-known/nodeinfo') 51 | if not doc: 52 | return 53 | try: 54 | doc = json.loads(doc) 55 | except json.JSONDecodeError: 56 | return 57 | 58 | url, highest_version = '', 0.0 59 | 60 | if doc.get('0'): 61 | # Buggy NodeInfo from certain old Hubzilla versions 62 | url = doc.get('0', {}).get('href') 63 | elif isinstance(doc.get('links'), dict): 64 | # Another buggy NodeInfo from certain old Hubzilla versions 65 | url = doc.get('links').get('href') 66 | else: 67 | for link in doc.get('links'): 68 | version = float(link.get('rel').split('/')[-1]) 69 | if highest_version < version <= HIGHEST_SUPPORTED_NODEINFO_VERSION: 70 | url, highest_version = link.get('href'), version 71 | 72 | if not url: 73 | return 74 | 75 | doc, status_code, error = fetch_document(url=url) 76 | if not doc: 77 | return 78 | try: 79 | doc = json.loads(doc) 80 | except json.JSONDecodeError: 81 | return 82 | return parse_nodeinfo_document(doc, host) 83 | 84 | 85 | def fetch_nodeinfo2_document(host): 86 | doc, status_code, error = fetch_document(host=host, path='/.well-known/x-nodeinfo2') 87 | if not doc: 88 | return 89 | try: 90 | doc = json.loads(doc) 91 | except json.JSONDecodeError: 92 | return 93 | return parse_nodeinfo2_document(doc, host) 94 | 95 | 96 | def fetch_statisticsjson_document(host): 97 | doc, status_code, error = fetch_document(host=host, path='/statistics.json') 98 | if not doc: 99 | return 100 | try: 101 | doc = json.loads(doc) 102 | except json.JSONDecodeError: 103 | return 104 | return parse_statisticsjson_document(doc, host) 105 | -------------------------------------------------------------------------------- /federation/hostmeta/schemas/nodeinfo-1.0.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://nodeinfo.diaspora.software/ns/schema/1.0#", 4 | "description": "NodeInfo schema version 1.0.", 5 | "type": "object", 6 | "additionalProperties": false, 7 | "required": [ 8 | "version", 9 | "software", 10 | "protocols", 11 | "services", 12 | "openRegistrations", 13 | "usage", 14 | "metadata" 15 | ], 16 | "properties": { 17 | "version": { 18 | "description": "The schema version, must be 1.0.", 19 | "enum": [ 20 | "1.0" 21 | ] 22 | }, 23 | "software": { 24 | "description": "Metadata about server software in use.", 25 | "type": "object", 26 | "additionalProperties": false, 27 | "required": [ 28 | "name", 29 | "version" 30 | ], 31 | "properties": { 32 | "name": { 33 | "description": "The canonical name of this server software.", 34 | "enum": [ 35 | "diaspora", 36 | "friendica", 37 | "redmatrix" 38 | ] 39 | }, 40 | "version": { 41 | "description": "The version of this server software.", 42 | "type": "string" 43 | } 44 | } 45 | }, 46 | "protocols": { 47 | "description": "The protocols supported on this server.", 48 | "type": "object", 49 | "additionalProperties": false, 50 | "required": [ 51 | "inbound", 52 | "outbound" 53 | ], 54 | "properties": { 55 | "inbound": { 56 | "description": "The protocols this server can receive traffic for.", 57 | "type": "array", 58 | "minItems": 1, 59 | "items": { 60 | "enum": [ 61 | "buddycloud", 62 | "diaspora", 63 | "friendica", 64 | "gnusocial", 65 | "libertree", 66 | "mediagoblin", 67 | "pumpio", 68 | "redmatrix", 69 | "smtp", 70 | "tent" 71 | ] 72 | } 73 | }, 74 | "outbound": { 75 | "description": "The protocols this server can generate traffic for.", 76 | "type": "array", 77 | "minItems": 1, 78 | "items": { 79 | "enum": [ 80 | "buddycloud", 81 | "diaspora", 82 | "friendica", 83 | "gnusocial", 84 | "libertree", 85 | "mediagoblin", 86 | "pumpio", 87 | "redmatrix", 88 | "smtp", 89 | "tent" 90 | ] 91 | } 92 | } 93 | } 94 | }, 95 | "services": { 96 | "description": "The third party sites this server can connect to via their application API.", 97 | "type": "object", 98 | "additionalProperties": false, 99 | "required": [ 100 | "inbound", 101 | "outbound" 102 | ], 103 | "properties": { 104 | "inbound": { 105 | "description": "The third party sites this server can retrieve messages from for combined display with regular traffic.", 106 | "type": "array", 107 | "minItems": 0, 108 | "items": { 109 | "enum": [ 110 | "appnet", 111 | "gnusocial", 112 | "pumpio" 113 | ] 114 | } 115 | }, 116 | "outbound": { 117 | "description": "The third party sites this server can publish messages to on the behalf of a user.", 118 | "type": "array", 119 | "minItems": 0, 120 | "items": { 121 | "enum": [ 122 | "appnet", 123 | "blogger", 124 | "buddycloud", 125 | "diaspora", 126 | "dreamwidth", 127 | "drupal", 128 | "facebook", 129 | "friendica", 130 | "gnusocial", 131 | "google", 132 | "insanejournal", 133 | "libertree", 134 | "linkedin", 135 | "livejournal", 136 | "mediagoblin", 137 | "myspace", 138 | "pinterest", 139 | "posterous", 140 | "pumpio", 141 | "redmatrix", 142 | "smtp", 143 | "tent", 144 | "tumblr", 145 | "twitter", 146 | "wordpress", 147 | "xmpp" 148 | ] 149 | } 150 | } 151 | } 152 | }, 153 | "openRegistrations": { 154 | "description": "Whether this server allows open self-registration.", 155 | "type": "boolean" 156 | }, 157 | "usage": { 158 | "description": "Usage statistics for this server.", 159 | "type": "object", 160 | "additionalProperties": false, 161 | "required": [ 162 | "users" 163 | ], 164 | "properties": { 165 | "users": { 166 | "description": "statistics about the users of this server.", 167 | "type": "object", 168 | "additionalProperties": false, 169 | "properties": { 170 | "total": { 171 | "description": "The total amount of on this server registered users.", 172 | "type": "integer", 173 | "minimum": 0 174 | }, 175 | "activeHalfyear": { 176 | "description": "The amount of users that signed in at least once in the last 180 days.", 177 | "type": "integer", 178 | "minimum": 0 179 | }, 180 | "activeMonth": { 181 | "description": "The amount of users that signed in at least once in the last 30 days.", 182 | "type": "integer", 183 | "minimum": 0 184 | } 185 | } 186 | }, 187 | "localPosts": { 188 | "description": "The amount of posts that were made by users that are registered on this server.", 189 | "type": "integer", 190 | "minimum": 0 191 | }, 192 | "localComments": { 193 | "description": "The amount of comments that were made by users that are registered on this server.", 194 | "type": "integer", 195 | "minimum": 0 196 | } 197 | } 198 | }, 199 | "metadata": { 200 | "description": "Free form key value pairs for software specific values. Clients should not rely on any specific key present.", 201 | "type": "object", 202 | "minProperties": 0, 203 | "additionalProperties": true 204 | } 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /federation/hostmeta/schemas/social-relay-well-known.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "id": "http://the-federation.info/social-relay/well-known-schema-v1.json", 4 | "type": "object", 5 | "properties": { 6 | "subscribe": { 7 | "type": "boolean" 8 | }, 9 | "scope": { 10 | "type": "string", 11 | "pattern": "^all|tags$" 12 | }, 13 | "tags": { 14 | "type": "array", 15 | "items": {"type": "string"}, 16 | "uniqueItems": true 17 | } 18 | }, 19 | "required": [ 20 | "subscribe", 21 | "scope", 22 | "tags" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /federation/hostmeta/templates/hcard_diaspora.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | $fullname 6 | 7 | 8 |
9 |

$fullname

10 |
11 |
12 |

User profile

13 |
14 |
Uid
15 |
16 | $guid 17 |
18 |
19 |
20 |
Nickname
21 |
22 | $username 23 |
24 |
25 |
26 |
First name
27 |
28 | $firstname 29 |
30 |
31 |
32 |
Family name
33 |
34 | $lastname 35 |
36 |
37 |
38 |
Full name
39 |
40 | $fullname 41 |
42 |
43 |
44 |
URL
45 |
46 | $hostname 47 |
48 |
49 |
50 |
Photo
51 |
52 | 53 |
54 |
55 |
56 |
Photo
57 |
58 | 59 |
60 |
61 |
62 |
Photo
63 |
64 | 65 |
66 |
67 |
68 |
Searchable
69 |
70 | $searchable 71 |
72 |
73 |
74 |
Key
75 |
76 |
$public_key
77 | 
78 |
79 |
80 |
81 |
82 |
83 | 84 | -------------------------------------------------------------------------------- /federation/inbound.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | from typing import Tuple, List, Callable 4 | 5 | from federation import identify_protocol_by_request 6 | from federation.types import UserType, RequestType 7 | 8 | logger = logging.getLogger("federation") 9 | 10 | 11 | def handle_receive( 12 | request: RequestType, 13 | user: UserType = None, 14 | sender_key_fetcher: Callable[[str], str] = None, 15 | skip_author_verification: bool = False 16 | ) -> Tuple[str, str, List]: 17 | """Takes a request and passes it to the correct protocol. 18 | 19 | Returns a tuple of: 20 | - sender id 21 | - protocol name 22 | - list of entities 23 | 24 | NOTE! The returned sender is NOT necessarily the *author* of the entity. By sender here we're 25 | talking about the sender of the *request*. If this object is being relayed by the sender, the author 26 | could actually be a different identity. 27 | 28 | :arg request: Request object of type RequestType - note not a HTTP request even though the structure is similar 29 | :arg user: User that will be passed to `protocol.receive` (only required on private encrypted content) 30 | MUST have a `private_key` and `id` if given. 31 | :arg sender_key_fetcher: Function that accepts sender handle and returns public key (optional) 32 | :arg skip_author_verification: Don't verify sender (test purposes, false default) 33 | :returns: Tuple of sender id, protocol name and list of entity objects 34 | """ 35 | logger.debug("handle_receive: processing request: %s", request) 36 | found_protocol = identify_protocol_by_request(request) 37 | 38 | logger.debug("handle_receive: using protocol %s", found_protocol.PROTOCOL_NAME) 39 | protocol = found_protocol.Protocol() 40 | sender, message = protocol.receive( 41 | request, user, sender_key_fetcher, skip_author_verification=skip_author_verification) 42 | logger.debug("handle_receive: sender %s, message %s", sender, message) 43 | 44 | mappers = importlib.import_module("federation.entities.%s.mappers" % found_protocol.PROTOCOL_NAME) 45 | entities = mappers.message_to_objects(message, sender, sender_key_fetcher, user) 46 | logger.debug("handle_receive: entities %s", entities) 47 | 48 | return sender, found_protocol.PROTOCOL_NAME, entities 49 | -------------------------------------------------------------------------------- /federation/protocols/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/protocols/__init__.py -------------------------------------------------------------------------------- /federation/protocols/activitypub/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/protocols/activitypub/__init__.py -------------------------------------------------------------------------------- /federation/protocols/activitypub/protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import re 4 | from typing import Callable, Tuple, Union, Dict 5 | 6 | from cryptography.exceptions import InvalidSignature 7 | from Crypto.PublicKey.RSA import RsaKey 8 | 9 | from federation.entities.activitypub.enums import ActorType 10 | from federation.entities.mixins import BaseEntity 11 | from federation.entities.utils import get_profile 12 | from federation.protocols.activitypub.signing import verify_request_signature 13 | from federation.types import UserType, RequestType 14 | from federation.utils.activitypub import retrieve_and_parse_document 15 | from federation.utils.text import decode_if_bytes 16 | 17 | 18 | logger = logging.getLogger('federation') 19 | 20 | PROTOCOL_NAME = "activitypub" 21 | 22 | 23 | def identify_id(id: str) -> bool: 24 | """ 25 | Try to identify whether this is an ActivityPub ID. 26 | """ 27 | return re.match(r'^https?://', id, flags=re.IGNORECASE) is not None 28 | 29 | 30 | def identify_request(request: RequestType) -> bool: 31 | """ 32 | Try to identify whether this is an ActivityPub request. 33 | """ 34 | # noinspection PyBroadException 35 | try: 36 | data = json.loads(decode_if_bytes(request.body)) 37 | if "@context" in data: 38 | return True 39 | except Exception: 40 | pass 41 | return False 42 | 43 | 44 | class Protocol: 45 | actor = None 46 | get_contact_key = None 47 | payload = None 48 | request = None 49 | sender = None 50 | user = None 51 | 52 | def __init__(self, request=None, get_contact_key=None): 53 | # this is required for calls to verify on GET requests 54 | self.request = request 55 | self.get_contact_key = get_contact_key 56 | 57 | def build_send(self, entity: BaseEntity, from_user: UserType, to_user_key: RsaKey = None) -> Union[str, Dict]: 58 | """ 59 | Build POST data for sending out to remotes. 60 | 61 | :param entity: The outbound ready entity for this protocol. 62 | :param from_user: The user sending this payload. Must have ``private_key`` and ``id`` properties. 63 | :param to_user_key: (Optional) Public key of user we're sending a private payload to. 64 | :returns: dict or string depending on if private or public payload. 65 | """ 66 | if hasattr(entity, "outbound_doc") and entity.outbound_doc is not None: 67 | # Use pregenerated outbound document 68 | rendered = entity.outbound_doc 69 | else: 70 | rendered = entity.sign_as2(sender=from_user) 71 | return rendered 72 | 73 | def extract_actor(self): 74 | if self.payload.get('type') in ActorType.values(): 75 | self.actor = self.payload.get('id') 76 | else: 77 | self.actor = self.payload.get('actor') 78 | 79 | def receive( 80 | self, 81 | request: RequestType, 82 | user: UserType = None, 83 | sender_key_fetcher: Callable[[str], str] = None, 84 | skip_author_verification: bool = False) -> Tuple[str, dict]: 85 | """ 86 | Receive a request. 87 | 88 | For testing purposes, `skip_author_verification` can be passed. Authorship will not be verified. 89 | """ 90 | self.user = user 91 | self.get_contact_key = sender_key_fetcher 92 | self.payload = json.loads(decode_if_bytes(request.body)) 93 | self.request = request 94 | self.extract_actor() 95 | # Verify the message is from who it claims to be 96 | if not skip_author_verification: 97 | try: 98 | # Verify the HTTP signature 99 | self.verify() 100 | except (ValueError, KeyError, InvalidSignature) as exc: 101 | logger.warning('HTTP signature verification failed: %s', exc) 102 | return self.actor, {} 103 | return self.sender, self.payload 104 | 105 | def verify(self): 106 | sig_struct = self.request.headers.get("Signature", None) 107 | if not sig_struct: 108 | raise ValueError("A signature is required but was not provided") 109 | 110 | # this should return a dict populated with the following keys: 111 | # keyId, algorithm, headers and signature 112 | sig = {i.split("=", 1)[0]: i.split("=", 1)[1].strip('"') for i in sig_struct.split(",")} 113 | 114 | signer = get_profile(key_id=sig.get('keyId')) 115 | if not signer: 116 | signer = retrieve_and_parse_document(sig.get('keyId')) 117 | self.sender = signer.id if signer else self.actor 118 | key = getattr(signer, 'public_key', None) 119 | if not key: 120 | key = self.get_contact_key(self.actor) if self.get_contact_key and self.actor else '' 121 | if key: 122 | # fallback to the author's key the client app may have provided 123 | logger.warning("Failed to retrieve keyId for %s, trying the actor's key", sig.get('keyId')) 124 | else: 125 | raise ValueError(f"No public key for {sig.get('keyId')}") 126 | 127 | verify_request_signature(self.request, key=key, algorithm=sig.get('algorithm',"")) 128 | -------------------------------------------------------------------------------- /federation/protocols/activitypub/signing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Thank you Funkwhale for inspiration on the HTTP signatures parts <3 3 | 4 | https://funkwhale.audio/ 5 | """ 6 | import datetime 7 | import logging 8 | from urllib.parse import urlsplit 9 | 10 | import pytz 11 | from Crypto.PublicKey.RSA import RsaKey 12 | from httpsig.sign_algorithms import PSS 13 | from httpsig.requests_auth import HTTPSignatureAuth 14 | from httpsig.verify import HeaderVerifier 15 | 16 | from federation.types import RequestType 17 | from federation.utils.network import parse_http_date 18 | from federation.utils.text import encode_if_text 19 | 20 | logger = logging.getLogger("federation") 21 | 22 | 23 | def get_http_authentication(private_key: RsaKey, private_key_id: str, digest: bool=True) -> HTTPSignatureAuth: 24 | """ 25 | Get HTTP signature authentication for a request. 26 | """ 27 | key = private_key.exportKey() 28 | headers = ["(request-target)", "user-agent", "host", "date"] 29 | if digest: headers.append('digest') 30 | return HTTPSignatureAuth( 31 | headers=headers, 32 | algorithm="rsa-sha256", 33 | secret=key, 34 | key_id=private_key_id, 35 | ) 36 | 37 | 38 | def verify_request_signature(request: RequestType, key: str="", algorithm: str=""): 39 | """ 40 | Verify HTTP signature in request against a public key. 41 | """ 42 | key = encode_if_text(key) 43 | date_header = request.headers.get("Date") 44 | if not date_header: 45 | raise ValueError("Request Date header is missing") 46 | 47 | ts = parse_http_date(date_header) 48 | dt = datetime.datetime.utcfromtimestamp(ts).replace(tzinfo=pytz.utc) 49 | past_delta = datetime.timedelta(hours=24) 50 | future_delta = datetime.timedelta(seconds=30) 51 | now = datetime.datetime.utcnow().replace(tzinfo=pytz.utc) 52 | if dt < now - past_delta or dt > now + future_delta: 53 | raise ValueError("Request Date is too far in future or past") 54 | 55 | path = getattr(request, 'path', urlsplit(request.url).path) 56 | if not HeaderVerifier(request.headers, key, method=request.method, 57 | path=path, sign_header='signature', 58 | sign_algorithm=PSS() if algorithm == 'hs2019' else None).verify(): 59 | raise ValueError("Invalid signature") 60 | -------------------------------------------------------------------------------- /federation/protocols/diaspora/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/protocols/diaspora/__init__.py -------------------------------------------------------------------------------- /federation/protocols/diaspora/encrypted.py: -------------------------------------------------------------------------------- 1 | import json 2 | from base64 import b64decode, b64encode 3 | 4 | from Crypto.Cipher import PKCS1_v1_5, AES 5 | from Crypto.Random import get_random_bytes 6 | from lxml import etree 7 | 8 | 9 | def pkcs7_pad(inp, block_size): 10 | """ 11 | Using the PKCS#7 padding scheme, pad to be a multiple of 12 | bytes. Ruby's AES encryption pads with this scheme, but 13 | pycrypto doesn't support it. 14 | 15 | Implementation copied from pyaspora: 16 | https://github.com/mjnovice/pyaspora/blob/master/pyaspora/diaspora/protocol.py#L209 17 | """ 18 | val = block_size - len(inp) % block_size 19 | if val == 0: 20 | return inp + (bytes([block_size]) * block_size) 21 | else: 22 | return inp + (bytes([val]) * val) 23 | 24 | 25 | def pkcs7_unpad(data): 26 | """ 27 | Remove the padding bytes that were added at point of encryption. 28 | 29 | Implementation copied from pyaspora: 30 | https://github.com/mjnovice/pyaspora/blob/master/pyaspora/diaspora/protocol.py#L209 31 | """ 32 | if isinstance(data, str): 33 | return data[0:-ord(data[-1])] 34 | else: 35 | return data[0:-data[-1]] 36 | 37 | 38 | class EncryptedPayload: 39 | """Diaspora encrypted JSON payloads.""" 40 | 41 | @staticmethod 42 | def decrypt(payload, private_key): 43 | """Decrypt an encrypted JSON payload and return the Magic Envelope document inside.""" 44 | cipher = PKCS1_v1_5.new(private_key) 45 | aes_key_str = cipher.decrypt(b64decode(payload.get("aes_key")), sentinel=None) 46 | aes_key = json.loads(aes_key_str.decode("utf-8")) 47 | key = b64decode(aes_key.get("key")) 48 | iv = b64decode(aes_key.get("iv")) 49 | encrypted_magic_envelope = b64decode(payload.get("encrypted_magic_envelope")) 50 | encrypter = AES.new(key, AES.MODE_CBC, iv) 51 | content = encrypter.decrypt(encrypted_magic_envelope) 52 | return etree.fromstring(pkcs7_unpad(content)) 53 | 54 | @staticmethod 55 | def get_aes_key_json(iv, key): 56 | return json.dumps({ 57 | "key": b64encode(key).decode("ascii"), 58 | "iv": b64encode(iv).decode("ascii"), 59 | }).encode("utf-8") 60 | 61 | @staticmethod 62 | def get_iv_key_encrypter(): 63 | iv = get_random_bytes(AES.block_size) 64 | key = get_random_bytes(32) 65 | encrypter = AES.new(key, AES.MODE_CBC, iv) 66 | return iv, key, encrypter 67 | 68 | @staticmethod 69 | def encrypt(payload, public_key): 70 | """ 71 | Encrypt a payload using an encrypted JSON wrapper. 72 | 73 | See: https://diaspora.github.io/diaspora_federation/federation/encryption.html 74 | 75 | :param payload: Payload document as a string. 76 | :param public_key: Public key of recipient as an RSA object. 77 | :return: Encrypted JSON wrapper as dict. 78 | """ 79 | iv, key, encrypter = EncryptedPayload.get_iv_key_encrypter() 80 | aes_key_json = EncryptedPayload.get_aes_key_json(iv, key) 81 | cipher = PKCS1_v1_5.new(public_key) 82 | aes_key = b64encode(cipher.encrypt(aes_key_json)) 83 | padded_payload = pkcs7_pad(payload.encode("utf-8"), AES.block_size) 84 | encrypted_me = b64encode(encrypter.encrypt(padded_payload)) 85 | return { 86 | "aes_key": aes_key.decode("utf-8"), 87 | "encrypted_magic_envelope": encrypted_me.decode("utf8"), 88 | } 89 | -------------------------------------------------------------------------------- /federation/protocols/diaspora/magic_envelope.py: -------------------------------------------------------------------------------- 1 | from base64 import urlsafe_b64encode, b64encode, urlsafe_b64decode 2 | 3 | from Crypto.Hash import SHA256 4 | from Crypto.PublicKey import RSA 5 | from Crypto.Signature import PKCS1_v1_5 6 | from lxml import etree 7 | 8 | from federation.exceptions import SignatureVerificationError 9 | from federation.utils.diaspora import fetch_public_key 10 | from federation.utils.text import decode_if_bytes 11 | 12 | NAMESPACE = "http://salmon-protocol.org/ns/magic-env" 13 | 14 | 15 | class MagicEnvelope: 16 | """Diaspora protocol magic envelope. 17 | 18 | Can be used to construct and deconstruct MagicEnvelope documents. 19 | 20 | When constructing, the following parameters should be given: 21 | * message 22 | * private_key 23 | * author_handle 24 | 25 | When deconstructing, the following should be given: 26 | * payload 27 | * public_key (optional, will be fetched if not given, using either 'sender_key_fetcher' or remote server) 28 | 29 | Upstream specification: http://diaspora.github.io/diaspora_federation/federation/magicsig.html 30 | """ 31 | 32 | nsmap = { 33 | "me": NAMESPACE, 34 | } 35 | 36 | def __init__(self, message=None, private_key=None, author_handle=None, payload=None, 37 | public_key=None, sender_key_fetcher=None, verify=False, doc=None): 38 | """ 39 | All parameters are optional. Some are required for signing, some for opening. 40 | 41 | :param message: Message string. Required to create a MagicEnvelope document. 42 | :param private_key: Private key RSA object. 43 | :param author_handle: Author signing the Magic Envelope, owns the private key. 44 | :param payload: Magic Envelope payload as str or bytes. 45 | :param public_key: Author public key in str format. 46 | :param sender_key_fetcher: Function to use to fetch sender public key, if public key not given. Will fall back 47 | to network fetch of the profile and the key. Function must take handle as only parameter and return 48 | a public key string. 49 | :param verify: Verify after creating object, defaults to False. 50 | :param doc: MagicEnvelope document. 51 | """ 52 | self._message = message 53 | self.private_key = private_key 54 | self.author_handle = author_handle 55 | self.payload = payload 56 | self.public_key = public_key 57 | self.sender_key_fetcher = sender_key_fetcher 58 | if payload: 59 | self.extract_payload() 60 | elif doc is not None: 61 | self.doc = doc 62 | else: 63 | self.doc = None 64 | if verify: 65 | self.verify() 66 | 67 | def extract_payload(self): 68 | payload = decode_if_bytes(self.payload) 69 | payload = payload.lstrip().encode("utf-8") 70 | self.doc = etree.fromstring(payload) 71 | self.author_handle = self.get_sender(self.doc) 72 | self.message = self.message_from_doc() 73 | 74 | def fetch_public_key(self): 75 | if self.sender_key_fetcher: 76 | self.public_key = self.sender_key_fetcher(self.author_handle) 77 | return 78 | self.public_key = fetch_public_key(self.author_handle) 79 | 80 | @staticmethod 81 | def get_sender(doc): 82 | """Get the key_id from the `sig` element which contains urlsafe_b64encoded Diaspora handle. 83 | 84 | :param doc: ElementTree document 85 | :returns: Diaspora handle 86 | """ 87 | key_id = doc.find(".//{%s}sig" % NAMESPACE).get("key_id") 88 | return urlsafe_b64decode(key_id).decode("utf-8") 89 | 90 | @property 91 | def message(self): 92 | return self._message 93 | 94 | @message.setter 95 | def message(self, value): 96 | self._message = value 97 | 98 | def message_from_doc(self): 99 | message = self.doc.find( 100 | ".//{http://salmon-protocol.org/ns/magic-env}data").text 101 | return urlsafe_b64decode(message.encode("ascii")) 102 | 103 | def create_payload(self): 104 | """Create the payload doc. 105 | 106 | Returns: 107 | str 108 | """ 109 | doc = etree.fromstring(self.message) 110 | self.payload = etree.tostring(doc, encoding="utf-8") 111 | self.payload = urlsafe_b64encode(self.payload).decode("ascii") 112 | return self.payload 113 | 114 | def _build_signature(self): 115 | """Create the signature using the private key.""" 116 | sig_contents = \ 117 | self.payload + "." + \ 118 | b64encode(b"application/xml").decode("ascii") + "." + \ 119 | b64encode(b"base64url").decode("ascii") + "." + \ 120 | b64encode(b"RSA-SHA256").decode("ascii") 121 | sig_hash = SHA256.new(sig_contents.encode("ascii")) 122 | cipher = PKCS1_v1_5.new(self.private_key) 123 | sig = urlsafe_b64encode(cipher.sign(sig_hash)) 124 | key_id = urlsafe_b64encode(bytes(self.author_handle, encoding="utf-8")) 125 | return sig, key_id 126 | 127 | def build(self): 128 | self.doc = etree.Element("{%s}env" % NAMESPACE, nsmap=self.nsmap) 129 | etree.SubElement(self.doc, "{%s}encoding" % NAMESPACE).text = 'base64url' 130 | etree.SubElement(self.doc, "{%s}alg" % NAMESPACE).text = 'RSA-SHA256' 131 | self.create_payload() 132 | etree.SubElement(self.doc, "{%s}data" % NAMESPACE, {"type": "application/xml"}).text = self.payload 133 | signature, key_id = self._build_signature() 134 | etree.SubElement(self.doc, "{%s}sig" % NAMESPACE, key_id=key_id).text = signature 135 | return self.doc 136 | 137 | def render(self): 138 | if self.doc is None: 139 | self.build() 140 | return etree.tostring(self.doc, encoding="unicode") 141 | 142 | def verify(self): 143 | """Verify Magic Envelope document against public key.""" 144 | if not self.public_key: 145 | self.fetch_public_key() 146 | data = self.doc.find(".//{http://salmon-protocol.org/ns/magic-env}data").text 147 | sig = self.doc.find(".//{http://salmon-protocol.org/ns/magic-env}sig").text 148 | sig_contents = '.'.join([ 149 | data, 150 | b64encode(b"application/xml").decode("ascii"), 151 | b64encode(b"base64url").decode("ascii"), 152 | b64encode(b"RSA-SHA256").decode("ascii") 153 | ]) 154 | sig_hash = SHA256.new(sig_contents.encode("ascii")) 155 | cipher = PKCS1_v1_5.new(RSA.importKey(self.public_key)) 156 | if not cipher.verify(sig_hash, urlsafe_b64decode(sig)): 157 | raise SignatureVerificationError("Signature cannot be verified using the given public key") 158 | -------------------------------------------------------------------------------- /federation/protocols/diaspora/protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from base64 import urlsafe_b64decode 4 | from typing import Callable, Tuple, Union, Dict 5 | from urllib.parse import unquote 6 | 7 | from Crypto.PublicKey.RSA import RsaKey 8 | from lxml import etree 9 | 10 | from federation.entities.mixins import BaseEntity 11 | from federation.exceptions import EncryptedMessageError, NoSenderKeyFoundError 12 | from federation.protocols.diaspora.encrypted import EncryptedPayload 13 | from federation.protocols.diaspora.magic_envelope import MagicEnvelope 14 | from federation.types import UserType, RequestType 15 | from federation.utils.diaspora import fetch_public_key 16 | from federation.utils.text import decode_if_bytes, encode_if_text, validate_handle 17 | 18 | logger = logging.getLogger("federation") 19 | 20 | PROTOCOL_NAME = "diaspora" 21 | PROTOCOL_NS = "https://joindiaspora.com/protocol" 22 | MAGIC_ENV_TAG = "{http://salmon-protocol.org/ns/magic-env}env" 23 | 24 | 25 | def identify_id(id: str) -> bool: 26 | """ 27 | Try to identify if this ID is a Diaspora ID. 28 | """ 29 | return validate_handle(id) 30 | 31 | 32 | # noinspection PyBroadException 33 | def identify_request(request: RequestType): 34 | """Try to identify whether this is a Diaspora request. 35 | 36 | Try first public message. Then private message. The check if this is a legacy payload. 37 | """ 38 | # Private encrypted JSON payload 39 | try: 40 | data = json.loads(decode_if_bytes(request.body)) 41 | if "encrypted_magic_envelope" in data: 42 | return True 43 | except Exception: 44 | pass 45 | # Public XML payload 46 | try: 47 | xml = etree.fromstring(encode_if_text(request.body)) 48 | if xml.tag == MAGIC_ENV_TAG: 49 | return True 50 | except Exception: 51 | pass 52 | return False 53 | 54 | 55 | class Protocol: 56 | """Diaspora protocol parts 57 | 58 | Original legacy implementation mostly taken from Pyaspora (https://github.com/lukeross/pyaspora). 59 | """ 60 | content = None 61 | doc = None 62 | get_contact_key = None 63 | user = None 64 | sender_handle = None 65 | 66 | def get_json_payload_magic_envelope(self, payload): 67 | """Encrypted JSON payload""" 68 | private_key = self._get_user_key() 69 | return EncryptedPayload.decrypt(payload=payload, private_key=private_key) 70 | 71 | def store_magic_envelope_doc(self, payload): 72 | """Get the Magic Envelope, trying JSON first.""" 73 | try: 74 | json_payload = json.loads(decode_if_bytes(payload)) 75 | except ValueError: 76 | # XML payload 77 | xml = unquote(decode_if_bytes(payload)) 78 | xml = xml.lstrip().encode("utf-8") 79 | logger.debug("diaspora.protocol.store_magic_envelope_doc: xml payload: %s", xml) 80 | self.doc = etree.fromstring(xml) 81 | else: 82 | logger.debug("diaspora.protocol.store_magic_envelope_doc: json payload: %s", json_payload) 83 | self.doc = self.get_json_payload_magic_envelope(json_payload) 84 | 85 | def receive( 86 | self, 87 | request: RequestType, 88 | user: UserType = None, 89 | sender_key_fetcher: Callable[[str], str] = None, 90 | skip_author_verification: bool = False) -> Tuple[str, str]: 91 | """Receive a payload. 92 | 93 | For testing purposes, `skip_author_verification` can be passed. Authorship will not be verified.""" 94 | self.user = user 95 | self.get_contact_key = sender_key_fetcher 96 | self.store_magic_envelope_doc(request.body) 97 | # Open payload and get actual message 98 | self.content = self.get_message_content() 99 | # Get sender handle 100 | self.sender_handle = self.get_sender() 101 | # Verify the message is from who it claims to be 102 | if not skip_author_verification: 103 | self.verify_signature() 104 | return self.sender_handle, self.content 105 | 106 | def _get_user_key(self): 107 | if not getattr(self.user, "private_key", None): 108 | raise EncryptedMessageError("Cannot decrypt private message without user key") 109 | return self.user.rsa_private_key 110 | 111 | def get_sender(self): 112 | return MagicEnvelope.get_sender(self.doc) 113 | 114 | def get_message_content(self): 115 | """ 116 | Given the Slap XML, extract out the payload. 117 | """ 118 | body = self.doc.find( 119 | ".//{http://salmon-protocol.org/ns/magic-env}data").text 120 | 121 | body = urlsafe_b64decode(body.encode("ascii")) 122 | 123 | logger.debug("diaspora.protocol.get_message_content: %s", body) 124 | return body 125 | 126 | def verify_signature(self): 127 | """ 128 | Verify the signed XML elements to have confidence that the claimed 129 | author did actually generate this message. 130 | """ 131 | if self.get_contact_key: 132 | sender_key = self.get_contact_key(self.sender_handle) 133 | else: 134 | sender_key = fetch_public_key(self.sender_handle) 135 | if not sender_key: 136 | raise NoSenderKeyFoundError("Could not find a sender contact to retrieve key") 137 | MagicEnvelope(doc=self.doc, public_key=sender_key, verify=True) 138 | 139 | def build_send(self, entity: BaseEntity, from_user: UserType, to_user_key: RsaKey = None) -> Union[str, Dict]: 140 | """ 141 | Build POST data for sending out to remotes. 142 | 143 | :param entity: The outbound ready entity for this protocol. 144 | :param from_user: The user sending this payload. Must have ``private_key`` and ``id`` properties. 145 | :param to_user_key: (Optional) Public key of user we're sending a private payload to. 146 | :returns: dict or string depending on if private or public payload. 147 | """ 148 | if entity.outbound_doc is not None: 149 | # Use pregenerated outbound document 150 | xml = entity.outbound_doc 151 | else: 152 | xml = entity.to_xml() 153 | me = MagicEnvelope(etree.tostring(xml), private_key=from_user.rsa_private_key, author_handle=from_user.handle) 154 | rendered = me.render() 155 | if to_user_key: 156 | return EncryptedPayload.encrypt(rendered, to_user_key) 157 | return rendered 158 | -------------------------------------------------------------------------------- /federation/protocols/diaspora/signatures.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode, b64encode 2 | 3 | from Crypto.Hash import SHA256 4 | from Crypto.PublicKey import RSA 5 | from Crypto.PublicKey.RSA import RsaKey 6 | from Crypto.Signature import PKCS1_v1_5 7 | 8 | 9 | def get_element_child_info(doc, attr): 10 | """Get information from child elements of this elementas a list since order is important. 11 | 12 | Don't include signature tags. 13 | 14 | :param doc: XML element 15 | :param attr: Attribute to get from the elements, for example "tag" or "text". 16 | """ 17 | props = [] 18 | for child in doc: 19 | if child.tag not in ["author_signature", "parent_author_signature"]: 20 | props.append(getattr(child, attr)) 21 | return props 22 | 23 | 24 | def _create_signature_hash(doc): 25 | props = get_element_child_info(doc, "text") 26 | content = ";".join(props) 27 | return SHA256.new(content.encode("utf-8")) 28 | 29 | 30 | def verify_relayable_signature(public_key, doc, signature): 31 | """ 32 | Verify the signed XML elements to have confidence that the claimed 33 | author did actually generate this message. 34 | """ 35 | sig_hash = _create_signature_hash(doc) 36 | cipher = PKCS1_v1_5.new(RSA.importKey(public_key)) 37 | return cipher.verify(sig_hash, b64decode(signature)) 38 | 39 | 40 | def create_relayable_signature(private_key: RsaKey, doc): 41 | sig_hash = _create_signature_hash(doc) 42 | cipher = PKCS1_v1_5.new(private_key) 43 | return b64encode(cipher.sign(sig_hash)).decode("ascii") 44 | -------------------------------------------------------------------------------- /federation/protocols/matrix/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/protocols/matrix/__init__.py -------------------------------------------------------------------------------- /federation/protocols/matrix/appservice.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | import yaml 4 | 5 | from federation.utils.django import get_configuration 6 | from federation.utils.matrix import get_matrix_configuration 7 | 8 | 9 | def get_registration_config() -> Dict: 10 | """ 11 | Get registration config. 12 | 13 | Requires Django support currently. 14 | """ 15 | config = get_configuration() 16 | matrix_config = get_matrix_configuration() 17 | 18 | if not matrix_config.get("appservice"): 19 | raise Exception("No appservice configured") 20 | 21 | return { 22 | "id": matrix_config["appservice"]["id"], 23 | "url": f"{config['base_url']}/matrix", 24 | "as_token": matrix_config["appservice"]["token"], 25 | "hs_token": matrix_config["appservice"]["token"], 26 | "sender_localpart": f'_{matrix_config["appservice"]["shortcode"]}', 27 | "namespaces": { 28 | # We reserve two namespaces 29 | # One is not exclusive, since we're interested in events of "real" users 30 | # One is exclusive, the ones that represent "remote to us but managed by us towards Matrix" 31 | "users": [ 32 | { 33 | "exclusive": False, 34 | "regex": "@.*", 35 | }, 36 | { 37 | "exclusive": True, 38 | "regex": f"@_{matrix_config['appservice']['shortcode']}_.*" 39 | }, 40 | ], 41 | "aliases": [ 42 | { 43 | "exclusive": False, 44 | "regex": "#.*", 45 | }, 46 | { 47 | "exclusive": True, 48 | "regex": f"#_{matrix_config['appservice']['shortcode']}_.*" 49 | }, 50 | ], 51 | "rooms": [], 52 | } 53 | } 54 | 55 | 56 | def print_registration_yaml(): 57 | """ 58 | Print registration file details. 59 | 60 | Requires Django support currently. 61 | """ 62 | registration = get_registration_config() 63 | print(yaml.safe_dump(registration)) 64 | -------------------------------------------------------------------------------- /federation/protocols/matrix/protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import re 4 | from typing import Callable, Tuple, List, Dict 5 | 6 | from federation.entities.matrix.entities import MatrixEntityMixin 7 | from federation.types import UserType, RequestType 8 | from federation.utils.text import decode_if_bytes 9 | 10 | logger = logging.getLogger('federation') 11 | 12 | PROTOCOL_NAME = "activitypub" 13 | 14 | 15 | def identify_id(identifier: str) -> bool: 16 | """ 17 | Try to identify whether this is a Matrix identifier. 18 | 19 | TODO fix, not entirely correct.. 20 | """ 21 | return re.match(r'^[@#!].*:.*$', identifier, flags=re.IGNORECASE) is not None 22 | 23 | 24 | def identify_request(request: RequestType) -> bool: 25 | """ 26 | Try to identify whether this is a Matrix request 27 | """ 28 | # noinspection PyBroadException 29 | try: 30 | data = json.loads(decode_if_bytes(request.body)) 31 | if "events" in data: 32 | return True 33 | except Exception: 34 | pass 35 | return False 36 | 37 | 38 | class Protocol: 39 | actor = None 40 | get_contact_key = None 41 | payload = None 42 | request = None 43 | user = None 44 | 45 | # noinspection PyUnusedLocal 46 | @staticmethod 47 | def build_send(entity: MatrixEntityMixin, *args, **kwargs) -> List[Dict]: 48 | """ 49 | Build POST data for sending out to the homeserver. 50 | 51 | :param entity: The outbound ready entity for this protocol. 52 | :returns: list of payloads 53 | """ 54 | return entity.payloads() 55 | 56 | def extract_actor(self): 57 | # TODO TBD 58 | pass 59 | 60 | def receive( 61 | self, 62 | request: RequestType, 63 | user: UserType = None, 64 | sender_key_fetcher: Callable[[str], str] = None, 65 | skip_author_verification: bool = False) -> Tuple[str, dict]: 66 | """ 67 | Receive a request. 68 | 69 | Matrix appservices will deliver 1+ events at a time. 70 | """ 71 | # TODO TBD 72 | return self.actor, self.payload 73 | -------------------------------------------------------------------------------- /federation/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/__init__.py -------------------------------------------------------------------------------- /federation/tests/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, DEFAULT 2 | 3 | import pytest 4 | import inspect 5 | import requests 6 | 7 | # noinspection PyUnresolvedReferences 8 | from federation.tests.fixtures.entities import * 9 | from federation.tests.fixtures.types import * 10 | from federation.tests.fixtures.keys import get_dummy_private_key 11 | 12 | 13 | @pytest.fixture(autouse=True) 14 | def disable_network_calls(monkeypatch): 15 | """Disable network calls.""" 16 | monkeypatch.setattr("requests.post", Mock()) 17 | 18 | class MockGetResponse(str): 19 | status_code = 200 20 | text = "" 21 | 22 | @staticmethod 23 | def raise_for_status(): 24 | pass 25 | 26 | saved_get = requests.get 27 | def side_effect(*args, **kwargs): 28 | if "pyld/documentloader" in inspect.stack()[4][1]: 29 | return saved_get(*args, **kwargs) 30 | return DEFAULT 31 | 32 | monkeypatch.setattr("requests.get", Mock(return_value=MockGetResponse, side_effect=side_effect)) 33 | 34 | class MockHeadResponse(dict): 35 | status_code = 200 36 | headers = {'Content-Type':'image/jpeg'} 37 | 38 | @staticmethod 39 | def raise_for_status(): 40 | pass 41 | 42 | monkeypatch.setattr("requests.head", Mock(return_value=MockHeadResponse)) 43 | 44 | @pytest.fixture 45 | def private_key(): 46 | return get_dummy_private_key() 47 | 48 | 49 | @pytest.fixture 50 | def public_key(private_key): 51 | return private_key.publickey().exportKey() 52 | -------------------------------------------------------------------------------- /federation/tests/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/django/__init__.py -------------------------------------------------------------------------------- /federation/tests/django/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "foobar" 2 | 3 | INSTALLED_APPS = tuple() 4 | 5 | FEDERATION = { 6 | "base_url": "https://example.com", 7 | "federation_id": "https://example.com/u/john/", 8 | "get_object_function": "federation.tests.django.utils.get_object_function", 9 | "get_private_key_function": "federation.tests.django.utils.get_private_key", 10 | "get_public_key_function": "federation.tests.django.utils.get_public_key", 11 | "get_profile_function": "federation.tests.django.utils.get_profile", 12 | "matrix_config_function": "federation.tests.django.utils.matrix_config_func", 13 | "process_payload_function": "federation.tests.django.utils.process_payload", 14 | "search_path": "/search?q=", 15 | "tags_path": "/tag/:tag:/", 16 | } 17 | -------------------------------------------------------------------------------- /federation/tests/django/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | # noinspection PyPackageRequirements 4 | from Crypto.PublicKey.RSA import RsaKey 5 | 6 | from federation.entities.base import Profile 7 | from federation.tests.fixtures.keys import get_dummy_private_key, get_dummy_public_key 8 | 9 | 10 | def dummy_profile(): 11 | return Profile( 12 | url=f"https://example.com/profile/1234/", 13 | atom_url=f"https://example.com/profile/1234/atom.xml", 14 | id=f"https://example.com/p/1234/", 15 | handle="foobar@example.com", 16 | guid="1234", 17 | name="Bob Bobértson", 18 | ) 19 | 20 | 21 | def get_object_function(object_id, signer=None): 22 | return dummy_profile() 23 | 24 | 25 | def get_private_key(identifier: str) -> RsaKey: 26 | return get_dummy_private_key() 27 | 28 | 29 | def get_public_key(identifier: str) -> RsaKey: 30 | return get_dummy_public_key() 31 | 32 | 33 | def get_profile(fid=None, handle=None, guid=None, request=None): 34 | return dummy_profile() 35 | 36 | 37 | def matrix_config_func() -> Dict: 38 | return { 39 | "homeserver_base_url": "https://matrix.domain.tld", 40 | "homeserver_domain_with_port": "matrix.domain.tld:443", 41 | "homeserver_name": "domain.tld", 42 | "appservice": { 43 | "id": "uniqueid", 44 | "shortcode": "myawesomeapp", 45 | "token": "secret_token", 46 | }, 47 | "identity_server_base_url": "https://id.domain.tld", 48 | "client_wellknown_other_keys": { 49 | "org.foo.key" "barfoo", 50 | }, 51 | "registration_shared_secret": "supersecretstring", 52 | } 53 | 54 | 55 | def process_payload(request): 56 | return True 57 | -------------------------------------------------------------------------------- /federation/tests/entities/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/entities/__init__.py -------------------------------------------------------------------------------- /federation/tests/entities/activitypub/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/entities/activitypub/__init__.py -------------------------------------------------------------------------------- /federation/tests/entities/activitypub/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/entities/activitypub/django/__init__.py -------------------------------------------------------------------------------- /federation/tests/entities/activitypub/django/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | from django.http import HttpResponse 6 | from django.test import RequestFactory 7 | from django.utils.decorators import method_decorator 8 | from django.views import View 9 | 10 | from federation.entities.activitypub.django.views import activitypub_object_view 11 | 12 | 13 | @activitypub_object_view 14 | def dummy_view(request, *args, **kwargs): 15 | return HttpResponse("foo") 16 | 17 | 18 | @activitypub_object_view 19 | def dummy_restricted_view(request, *args, **kwargs): 20 | return HttpResponse("foo") 21 | 22 | 23 | @method_decorator(activitypub_object_view, name='dispatch') 24 | class DummyView(View): 25 | def get(self, request, *args, **kwargs): 26 | return HttpResponse("foo") 27 | 28 | def post(self, request, *args, **kwargs): 29 | return HttpResponse("foo") 30 | 31 | 32 | @method_decorator(activitypub_object_view, name='dispatch') 33 | class DummyRestrictedView(View): 34 | def get(self, request, *args, **kwargs): 35 | return HttpResponse("foo") 36 | 37 | def post(self, request, *args, **kwargs): 38 | return HttpResponse("foo") 39 | 40 | 41 | def dummy_get_object_function(request, signer=None): 42 | if request.method == 'GET': 43 | return False 44 | return True 45 | 46 | 47 | class TestActivityPubObjectView: 48 | def test_falls_back_if_not_right_content_type(self): 49 | request = RequestFactory().get("/") 50 | response = dummy_view(request=request) 51 | 52 | assert response.content == b'foo' 53 | 54 | def test_falls_back_if_not_right_content_type__cbv(self): 55 | request = RequestFactory().get("/") 56 | view = DummyView.as_view() 57 | response = view(request=request) 58 | 59 | assert response.content == b'foo' 60 | 61 | def test_receives_messages_to_inbox(self): 62 | request = RequestFactory().post("/u/bla/inbox/", data='{"foo": "bar"}', content_type='application/json') 63 | response = dummy_view(request=request) 64 | 65 | assert response.status_code == 202 66 | 67 | def test_receives_messages_to_inbox__cbv(self): 68 | request = RequestFactory().post("/u/bla/inbox/", data='{"foo": "bar"}', content_type="application/json") 69 | view = DummyView.as_view() 70 | response = view(request=request) 71 | 72 | assert response.status_code == 202 73 | 74 | @pytest.mark.parametrize('content_type', ( 75 | 'application/json', 'application/activity+json', 'application/ld+json', 76 | 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 77 | 'application/activity+json, application/ld+json', 78 | )) 79 | def test_renders_as2(self, content_type): 80 | request = RequestFactory().get("/", HTTP_ACCEPT=content_type) 81 | response = dummy_view(request=request) 82 | 83 | assert response.status_code == 200 84 | content = json.loads(response.content) 85 | assert content['name'] == 'Bob Bobértson' 86 | assert response['Content-Type'] == 'application/activity+json' 87 | 88 | @pytest.mark.parametrize('content_type', ( 89 | 'application/json', 'application/activity+json', 'application/ld+json', 90 | 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 91 | 'application/activity+json, application/ld+json', 92 | )) 93 | def test_renders_as2__cbv(self, content_type): 94 | request = RequestFactory().get("/", HTTP_ACCEPT=content_type) 95 | view = DummyView.as_view() 96 | response = view(request=request) 97 | 98 | assert response.status_code == 200 99 | content = json.loads(response.content) 100 | assert content['name'] == 'Bob Bobértson' 101 | assert response['Content-Type'] == 'application/activity+json' 102 | 103 | def test_restricted_view__denied_when_not_authorized(self): 104 | request = RequestFactory().get("/", HTTP_ACCEPT='application/activity+json') 105 | with patch("federation.tests.django.utils.get_object_function", new=dummy_get_object_function): 106 | response = dummy_restricted_view(request=request) 107 | 108 | assert response.status_code == 404 109 | 110 | def test_restricted_view__denied_when_not_authorized__cbv(self): 111 | request = RequestFactory().get("/", HTTP_ACCEPT='application/activity+json') 112 | view = DummyRestrictedView.as_view() 113 | with patch("federation.tests.django.utils.get_object_function", new=dummy_get_object_function): 114 | response = view(request=request) 115 | 116 | assert response.status_code == 404 117 | -------------------------------------------------------------------------------- /federation/tests/entities/diaspora/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/entities/diaspora/__init__.py -------------------------------------------------------------------------------- /federation/tests/entities/diaspora/test_utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | from unittest.mock import patch, Mock 4 | 5 | import arrow 6 | from lxml import etree 7 | 8 | from federation.entities.base import Post, Profile 9 | from federation.entities.diaspora.entities import DiasporaPost 10 | from federation.entities.diaspora.utils import ( 11 | get_full_xml_representation, format_dt, add_element_to_doc) 12 | from federation.entities.utils import get_base_attributes 13 | 14 | 15 | class TestGetBaseAttributes: 16 | def test_get_base_attributes_returns_only_intended_attributes(self, diasporapost, diasporaprofile): 17 | entity = diasporapost 18 | attrs = get_base_attributes(entity).keys() 19 | assert set(attrs) == { 20 | 'activity', 'actor_id', 'created_at', 'guid', 'handle', 'id', 21 | 'provider_display_name', 'public', 'raw_content'} 22 | entity = diasporaprofile 23 | attrs = get_base_attributes(entity).keys() 24 | assert set(attrs) == { 25 | 'created_at', 'guid', 'handle', 'id', 'image_urls', 'inboxes', 26 | 'name', 'nsfw', 'public', 'raw_content', 'tag_list'} 27 | 28 | 29 | class TestGetFullXMLRepresentation: 30 | @patch.object(DiasporaPost, "validate", new=Mock()) 31 | def test_returns_xml_document(self): 32 | entity = Post() 33 | document = get_full_xml_representation(entity, "") 34 | document = re.sub(r".*", "", document) # Dates are annoying to compare 35 | assert document == "" \ 36 | "false" \ 37 | "" 38 | 39 | 40 | class TestFormatDt: 41 | def test_formatted_string_returned_from_tz_aware_datetime(self): 42 | dt = arrow.get(datetime.datetime(2017, 1, 28, 3, 2, 3), "Europe/Helsinki").datetime 43 | assert format_dt(dt) == "2017-01-28T01:02:03Z" 44 | 45 | 46 | def test_add_element_to_doc(): 47 | # Replacing value 48 | doc = etree.fromstring("foobarbarfoo" 49 | "") 50 | add_element_to_doc(doc, "parent_author_signature", "newsig") 51 | assert etree.tostring(doc) == b"foobarnewsig" \ 52 | b"" 53 | # Adding value to an empty tag 54 | doc = etree.fromstring("foobar") 55 | add_element_to_doc(doc, "parent_author_signature", "newsig") 56 | assert etree.tostring(doc) == b"foobarnewsig" \ 57 | b"" 58 | # Adding missing tag 59 | doc = etree.fromstring("foobar") 60 | add_element_to_doc(doc, "parent_author_signature", "newsig") 61 | assert etree.tostring(doc) == b"foobarnewsig" \ 62 | b"" 63 | -------------------------------------------------------------------------------- /federation/tests/entities/matrix/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/entities/matrix/__init__.py -------------------------------------------------------------------------------- /federation/tests/entities/test_base.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock 2 | 3 | import pytest 4 | 5 | from federation.entities.base import Relationship, Profile, Image 6 | from federation.entities.mixins import PublicMixin, RawContentMixin, BaseEntity 7 | from federation.tests.factories.entities import TaggedPostFactory, PostFactory, ShareFactory, RetractionFactory, \ 8 | ImageFactory, FollowFactory 9 | 10 | 11 | class TestPostEntityTags: 12 | def test_post_entity_returns_list_of_tags(self): 13 | post = TaggedPostFactory() 14 | assert post.tags == ["snakecase", "tagone", "tagthree", "tagtwo", "upper"] 15 | 16 | def test_post_entity_without_raw_content_tags_returns_empty_set(self): 17 | post = PostFactory(raw_content=None) 18 | assert post.tags == [] 19 | 20 | 21 | class TestBaseEntityCallsValidateMethods: 22 | def test_entity_calls_attribute_validate_method(self): 23 | post = PostFactory() 24 | post.validate_location = Mock() 25 | post.validate() 26 | assert post.validate_location.call_count == 1 27 | 28 | def test_entity_calls_main_validate_methods(self): 29 | post = PostFactory() 30 | post._validate_required = Mock() 31 | post._validate_attributes = Mock() 32 | post._validate_empty_attributes = Mock() 33 | post._validate_children = Mock() 34 | post.validate() 35 | assert post._validate_required.call_count == 1 36 | assert post._validate_attributes.call_count == 1 37 | assert post._validate_empty_attributes.call_count == 1 38 | assert post._validate_children.call_count == 1 39 | 40 | def test_validate_children(self): 41 | post = PostFactory() 42 | image = Image() 43 | profile = Profile() 44 | post._children = [image] 45 | post._validate_children() 46 | post._children = [profile] 47 | with pytest.raises(ValueError): 48 | post._validate_children() 49 | 50 | 51 | class TestPublicMixinValidate: 52 | def test_validate_public_raises_on_low_length(self): 53 | public = PublicMixin(public="foobar") 54 | with pytest.raises(ValueError): 55 | public.validate() 56 | 57 | 58 | class TestEntityRequiredAttributes: 59 | def test_entity_checks_for_required_attributes(self): 60 | entity = BaseEntity() 61 | entity._required = ["foobar"] 62 | with pytest.raises(ValueError): 63 | entity.validate() 64 | 65 | def test_validate_checks_required_values_are_not_empty(self): 66 | entity = RawContentMixin(raw_content=None) 67 | with pytest.raises(ValueError): 68 | entity.validate() 69 | entity = RawContentMixin(raw_content="") 70 | with pytest.raises(ValueError): 71 | entity.validate() 72 | 73 | 74 | class TestRelationshipEntity: 75 | def test_instance_creation(self): 76 | entity = Relationship(handle="bob@example.com", target_handle="alice@example.com", relationship="following") 77 | assert entity 78 | 79 | def test_instance_creation_validates_relationship_value(self): 80 | with pytest.raises(ValueError): 81 | entity = Relationship(handle="bob@example.com", target_handle="alice@example.com", relationship="hating") 82 | entity.validate() 83 | 84 | 85 | class TestProfileEntity: 86 | def test_instance_creation(self): 87 | entity = Profile(handle="bob@example.com", raw_content="foobar") 88 | assert entity 89 | 90 | def test_instance_creation_validates_email_value(self): 91 | with pytest.raises(ValueError): 92 | entity = Profile(handle="bob@example.com", raw_content="foobar", email="foobar") 93 | entity.validate() 94 | 95 | def test_guid_is_mandatory(self): 96 | entity = Profile(handle="bob@example.com", raw_content="foobar") 97 | with pytest.raises(ValueError): 98 | entity.validate() 99 | 100 | 101 | class TestImageEntity: 102 | def test_instance_creation(self): 103 | entity = ImageFactory() 104 | entity.validate() 105 | 106 | 107 | class TestRetractionEntity: 108 | def test_instance_creation(self): 109 | entity = RetractionFactory() 110 | entity.validate() 111 | 112 | 113 | class TestFollowEntity: 114 | def test_instance_creation(self): 115 | entity = FollowFactory() 116 | entity.validate() 117 | 118 | 119 | class TestShareEntity: 120 | def test_instance_creation(self): 121 | entity = ShareFactory() 122 | entity.validate() 123 | 124 | 125 | class TestRawContentMixin: 126 | @pytest.mark.skip 127 | def test_rendered_content(self, post): 128 | assert post.rendered_content == """

One more test before sleep 😅 This time with an image.

129 |

""" 130 | -------------------------------------------------------------------------------- /federation/tests/factories/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/factories/__init__.py -------------------------------------------------------------------------------- /federation/tests/factories/entities.py: -------------------------------------------------------------------------------- 1 | from random import shuffle 2 | import factory 3 | from factory import fuzzy 4 | 5 | from federation.entities.base import Post, Profile, Share, Retraction, Image, Follow 6 | from federation.entities.diaspora.entities import DiasporaPost 7 | 8 | 9 | class ActorIDMixinFactory(factory.Factory): 10 | actor_id = factory.Faker('uri') 11 | 12 | 13 | class EntityTypeMixinFactory(factory.Factory): 14 | entity_type = 'Post' 15 | 16 | 17 | class IDMixinFactory(factory.Factory): 18 | id = factory.Faker('uri') 19 | 20 | 21 | class PublicMixinFactory(factory.Factory): 22 | public = factory.Faker("pybool") 23 | 24 | 25 | class TargetIDMixinFactory(factory.Factory): 26 | target_id = factory.Faker('uri') 27 | 28 | 29 | class RawContentMixinFactory(factory.Factory): 30 | raw_content = fuzzy.FuzzyText(length=300) 31 | 32 | 33 | class FollowFactory(ActorIDMixinFactory, TargetIDMixinFactory): 34 | class Meta: 35 | model = Follow 36 | 37 | following = factory.Faker("pybool") 38 | 39 | 40 | class PostFactory(ActorIDMixinFactory, IDMixinFactory, RawContentMixinFactory, factory.Factory): 41 | class Meta: 42 | model = Post 43 | 44 | 45 | class TaggedPostFactory(PostFactory): 46 | 47 | @factory.lazy_attribute 48 | def raw_content(self): 49 | parts = [] 50 | for tag in ["tagone", "tagtwo", "tagthree", "tagthree", "SnakeCase", "UPPER", ""]: 51 | parts.append(fuzzy.FuzzyText(length=50).fuzz()) 52 | parts.append("#%s" % tag) 53 | shuffle(parts) 54 | return " ".join(parts) 55 | 56 | 57 | class DiasporaPostFactory(PostFactory): 58 | class Meta: 59 | model = DiasporaPost 60 | 61 | 62 | class ImageFactory(ActorIDMixinFactory, IDMixinFactory, factory.Factory): 63 | class Meta: 64 | model = Image 65 | 66 | url = factory.Faker('uri') 67 | name = factory.Faker('slug') 68 | 69 | 70 | class ProfileFactory(IDMixinFactory, RawContentMixinFactory, factory.Factory): 71 | class Meta: 72 | model = Profile 73 | 74 | name = fuzzy.FuzzyText(length=30) 75 | public_key = fuzzy.FuzzyText(length=300) 76 | 77 | 78 | class RetractionFactory(ActorIDMixinFactory, EntityTypeMixinFactory, TargetIDMixinFactory, factory.Factory): 79 | class Meta: 80 | model = Retraction 81 | 82 | 83 | class ShareFactory(ActorIDMixinFactory, EntityTypeMixinFactory, IDMixinFactory, PublicMixinFactory, 84 | TargetIDMixinFactory, factory.Factory): 85 | class Meta: 86 | model = Share 87 | 88 | raw_content = "" 89 | provider_display_name = "" 90 | to = ["https://www.w3.org/ns/activitystreams#Public"] 91 | -------------------------------------------------------------------------------- /federation/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /federation/tests/fixtures/keys.py: -------------------------------------------------------------------------------- 1 | from Crypto.PublicKey import RSA 2 | 3 | PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\n" \ 4 | "MIIEogIBAAKCAQEAiY2JBgMV90ULt0btku198l6wGuzn3xCcHs+eBZHL2C+XWRA3\n" \ 5 | "BVDThSBj19dKXehfDphQ5u/Omfm76ImajEPHGBiYtZT7AgcO15zvm+JCpbREbdOV\n" \ 6 | "QkST3ANyqCzi+Fk0ZWRwXQTR9m64ML++42iK0BESUbbrVnKipZJ1tE73xs1XBM8J\n" \ 7 | "DCOIdM2VBVdDArNJZHGzqugEbDzwh0SqEsKYLE7uzst+eY9vIAbyX80pNzC/d1J8\n" \ 8 | "3Pia5WvRV0gtllkMXlGnTIortDJuEr496a8UqfPWDWNg4scCca6aSk/13Q8ClEbP\n" \ 9 | "X1sdW4s9yW9OmGg0VMZj+Tca3Jls/3FJosH0yQIDAQABAoIBADVdDGihr9bjGX17\n" \ 10 | "7dUPf8oUg/ueJwJ5/idR4ntEqbFwHSY3TTEpvzWpcDKfWkF+UcpmuxQsupkvsn+v\n" \ 11 | "Sp7Z+JZXjH79kjeiJ1bskmSGbda9TcLRz9kKo9Y6HDQ0XcV9Tf977L+ZjB8vqxN2\n" \ 12 | "gAbXWusHhHThIwHBrWnQnQtbi3K7SzVT3OK0WFfsoAZgYSzfS+4LE0Gs9+ZcK8q7\n" \ 13 | "So4BE7/jSjf+Baux92Hes5spi73ltx/BsyEYR5XQVzWfIUg4sX3VDRbpBTW+DBqA\n" \ 14 | "G0kUh3CjlsPkZeRSiPrAfk610hQr4HLInGxPkaK+8Fuui2ofM0qYwOeGkNXqlY4Z\n" \ 15 | "huhXcFUCgYEAtX0/KoF9k52FbSJdl+2ekeBluU9fJyB3SpGyk5MTKeoAo9I82KyJ\n" \ 16 | "tens+5ebj8rUZYHTQfjHsm0ihy4F3GH+huPw4B+RQ8h5BLkU5+KC6pT60M+eMj13\n" \ 17 | "bJZkm9n4bInDx9f8Aj4XSG+P2g8h9dBSSm4Ewiqp4CtFTY58uujvMu8CgYEAwgaE\n" \ 18 | "5vanfxfk08qvZ7WSxUGfZxp6R2sLjfyB2qL4XJk/8ZpLB17kpYdGhhpk5qWRNmlH\n" \ 19 | "vetLp3RZoZRB0JJYq++IkiIq1gfnghgKcSbM8sMXvIT0icBXZU/XTzBVReeRYf9P\n" \ 20 | "Sjc+zD/W6L2lXhdZ7z1rGHHvEH/bMQEj3vIQc8cCgYAN6awN9h9KUakI1LmYC/87\n" \ 21 | "75fcvNjuhu6eKM0nwv6VF/s0k8lWUuO7rlMcdmLWgxYFMg6f4BJu+y7KbhzE6D46\n" \ 22 | "2P5+L+1S5OtiEU4o+JRQp1sS5teZwlyFVoIf8HW63FTF3SjUgy4Fv4enj8Fqtq2Y\n" \ 23 | "RxbWS676IFcPuvyU14Z+wQKBgARZWw9GRhjeMz3gFDBx7HlJcEZCXK1PI/Ipz8tT\n" \ 24 | "zdddhAZpW/ctVFi1gIou+0YEPg4HLBmAtbBqNjwd85+2OBCajOghpe4oPTM4ULua\n" \ 25 | "kAt8/gI2xLh1vD/EG2JmBfNMLoEQ1Pkn5dt0LuAGqDdEtLpdGRJyM1aeVw5xJRmx\n" \ 26 | "OVcvAoGAO2keIaA0uB9SszdgovK22pzmkluCIB7ldcjuf/zkjt62nSOOa3mtEAue\n" \ 27 | "t/b5Jw+yQVBqNkfJwOMykCxcYs4IEuJelbOYSCp3GmW014nDxYbe5y1Q40drdTro\n" \ 28 | "w6Y5FnjFw022w+M3exyH6ZtxcmG6buDbp2F/SPD/FnYy5IFCDig=\n" \ 29 | "-----END RSA PRIVATE KEY-----" 30 | 31 | # Not related to above private key 32 | PUBKEY = "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuCfU1G5X+3O6vPdSz6QY\nSFbgdbv3KPv" \ 33 | "xHi8tRmlyOLdLt5i1eqsy2WCW1iYNijiCL7OfbrvymBQxe3GA9S64\nVuavwzQ8nO7nzpNMqxY5tBXsBM1lECCHDOvm5dzINXWT9Sg7P1" \ 34 | "8iIxE/2wQEgMUL\nAeVbJtAriXM4zydL7c91agFMJu1aHp0lxzoH8I13xzUetGMutR1tbcfWvoQvPAoU\n89uAz5j/DFMhWrkVEKGeWt1" \ 35 | "YtHMmJqpYqR6961GDlwRuUsOBsLgLLVohzlBsTBSn\n3580o2E6G3DEaX0Az9WB9ylhNeV/L/PP3c5htpEyoPZSy1pgtut6TRYQwC8wns" \ 36 | "qO\nbVIbFBkrKoaRDyVCnpMuKdDNLZqOOfhzas+SWRAby6D8VsXpPi/DpeS9XkX0o/uH\nJ9N49GuYMSUGC8gKtaddD13pUqS/9rpSvLD" \ 37 | "rrDQe5Lhuyusgd28wgEAPCTmM3pEt\nQnlxEeEmFMIn3OBLbEDw5TFE7iED0z7a4dAkqqz8KCGEt12e1Kz7ujuOVMxJxzk6\nNtwt40Sq" \ 38 | "EOPcdsGHAA+hqzJnXUihXfmtmFkropaCxM2f+Ha0bOQdDDui5crcV3sX\njShmcqN6YqFzmoPK0XM9P1qC+lfL2Mz6bHC5p9M8/FtcM46" \ 39 | "hCj1TF/tl8zaZxtHP\nOrMuFJy4j4yAsyVy3ddO69ECAwEAAQ==\n-----END PUBLIC KEY-----\n" 40 | 41 | SIGNATURE = "A/vVRxM3V1ceEH1JrnPOaIZGM3gMjw/fnT9TgUh3poI4q9eH95AIoig+3eTA8XFuGvuo0tivxci4e0NJ1VLVkl/aqp8rvBNrRI1RQk" \ 42 | "n2WVF6zk15Gq6KSia/wyzyiJHGxNGM8oFY4qPfNp6K+8ydUti22J11tVBEvQn+7FPAoloF2Xz1waK48ZZCFs8Rxzj+4jlz1PmuXCnT" \ 43 | "j7v7GYS1Rb6sdFz4nBSuVk5X8tGOSXIRYxPgmtsDRMRrvDeEK+v3OY6VnT8dLTckS0qCwTRUULub1CGwkz/2mReZk/M1W4EbUnugF5" \ 44 | "ptslmFqYDYJZM8PA/g89EKVpkx2gaFbsC4KXocWnxHNiue18rrFQ5hMnDuDRiRybLnQkxXbE/HDuLdnognt2S5wRshPoZmhe95v3qq" \ 45 | "/5nH/GX1D7VmxEEIG9fX+XX+Vh9kzO9bLbwoJZwm50zXxCvrLlye/2JU5Vd2Hbm4aMuAyRAZiLS/EQcBlsts4DaFu4txe60HbXSh6n" \ 46 | "qNofGkusuzZnCd0VObOpXizrI8xNQzZpjJEB5QqE2gbCC2YZNdOS0eBGXw42dAXa/QV3jZXGES7DdQlqPqqT3YjcMFLiRrWQR8cl4h" \ 47 | "JIBRpV5piGyLmMMKYrWu7hQSrdRAEL3K6mNZZU6/yoG879LjtQbVwaFGPeT29B4zBE97FIo=" 48 | 49 | SIGNATURE2 = "Xla/AlirMihx72hehGMgpKILRUA2ZkEhFgVc65sl80iN+F62yQdSikGyUQVL+LaGNUgmzgK0zEahamfaMFep/9HE2FWuXlTCM+ZXx" \ 50 | "OhGWUnjkGW9vi41/Turm7ALzaJoFm1f3Iv4nh1sRD1jySzlZvYwrq4LwmgZ8r0M+Q6xUSIIJfgS8Zjmp43strKo28vKT+DmUKu9Fg" \ 51 | "jZWjW3S8WPPJFO0UqA0b1UQspmNLZOVxsNpa0OCM1pofJvT09n6xG+byV30Bed27Kw+D3fzfYq5xvohyeCyliTq8LHnOykecki3Y2" \ 52 | "Pvl1qsxxBehlwc/WH8yIUiwC2Du6zY61tN3LGgMAoIFl40Roo1z/I7YfOy4ZCukOGqqyiLdjoXxIVQqqsPtKsrVXS+A9OQ+sVESgw" \ 53 | "f8jeEIw/KXLVB/aEyrZJXQR1pBfqkOTCSnAfZVBSjJyxhanS/8iGmnRV5zz3auYMLR9aA8QHjV/VZOj0Bxhuba9VIzJlY9XoUt5Vs" \ 54 | "h3uILJM3uVJzSjlZV+Jw3O+NdQFnZyh7m1+eJUMQJ8i0Sr3sMLsdb9me/I0HueXCa5eBHAoTtAyQgS4uN4NMhvpqrB/lQCx7pqnkt" \ 55 | "xiCO/bUEZONQjWrvJT+EfD+I0UMFtPFiGDzJ0yi0Ah7LxSTGEGPFZHH5RgsJA8lJwGMCUtc9Cpy8A=" 56 | 57 | SIGNATURE3 = "hVdLwsWXe6yVy88m9H1903+Bj/DjSGsYL+ZIpEz+G6u/aVx6QfsvnWHzasjqN8SU+brHfL0c8KrapWcACO+jyCuXlHMZb9zKmJkHR" \ 58 | "FSOiprCJ3tqNpv/4MIa9CXu0YDqnLHBSyxS01luKw3EqgpWPQdYcqDpOkjjTOq45dQC0PGHA/DXjP7LBptV9AwW200LIcL5Li8tDU" \ 59 | "a8VSQybspDDfDpXU3+Xl5tJIBVS4ercPczp5B39Cwne4q2gyj/Y5RdIoX5RMqmFhfucw1he38T1oRC9AHTJqj4CBcDt7gc6jPHuzk" \ 60 | "N7u1eUf0IK3+KTDKsCkkoHcGaoxT+NeWcS8Ki1A==" 61 | 62 | XML = "0dd40d800db1013514416c626dd5570369ab2b83-aa69-4456-ad0a-dd669" \ 63 | "7f54714Woop Woopjaywink@iliketoast.net" 64 | 65 | XML2 = "d728fe501584013514526c626dd55703d641bd35-8142-414e-a12d-f956cc2c1bb9" \ 66 | "What about the mystical problem with 👍 (pt2 with more logging)" \ 67 | "jaywink@iliketoast.net" 68 | 69 | 70 | def get_dummy_private_key(): 71 | return RSA.importKey(PRIVATE_KEY) 72 | 73 | 74 | def get_dummy_public_key(): 75 | return PUBKEY 76 | -------------------------------------------------------------------------------- /federation/tests/fixtures/payloads/__init__.py: -------------------------------------------------------------------------------- 1 | from .activitypub import * # noqa 2 | from .diaspora import * # noqa 3 | -------------------------------------------------------------------------------- /federation/tests/fixtures/types.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from federation.tests.fixtures.keys import get_dummy_private_key 4 | from federation.types import UserType 5 | 6 | 7 | @pytest.fixture 8 | def usertype(): 9 | return UserType( 10 | id="https://localhost/profile", 11 | private_key=get_dummy_private_key(), 12 | ) 13 | -------------------------------------------------------------------------------- /federation/tests/hostmeta/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/hostmeta/__init__.py -------------------------------------------------------------------------------- /federation/tests/hostmeta/django/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/hostmeta/django/__init__.py -------------------------------------------------------------------------------- /federation/tests/hostmeta/django/test_generators.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import patch, Mock 3 | 4 | from django.test import RequestFactory 5 | 6 | from federation.hostmeta.django import rfc7033_webfinger_view 7 | from federation.hostmeta.django.generators import nodeinfo2_view 8 | from federation.utils.django import get_function_from_config 9 | from federation.tests.fixtures.hostmeta import NODEINFO2_10_DOC 10 | 11 | 12 | def test_get_function_from_config(): 13 | func = get_function_from_config("get_profile_function") 14 | assert callable(func) 15 | 16 | 17 | class TestNodeInfo2View: 18 | def test_returns_400_if_not_configured(self): 19 | request = RequestFactory().get('/.well-known/x-nodeinfo2') 20 | response = nodeinfo2_view(request) 21 | assert response.status_code == 400 22 | 23 | @patch("federation.hostmeta.django.generators.get_function_from_config") 24 | def test_returns_200(self, mock_get_func): 25 | mock_get_func.return_value = Mock(return_value=json.loads(NODEINFO2_10_DOC)) 26 | request = RequestFactory().get('/.well-known/x-nodeinfo2') 27 | response = nodeinfo2_view(request) 28 | assert response.status_code == 200 29 | 30 | 31 | class TestRFC7033WebfingerView: 32 | @patch("federation.hostmeta.django.generators.get_function_from_config") 33 | def test_handle_lowercased(self, mock_get_func): 34 | mock_get_profile = Mock(side_effect=Exception) 35 | mock_get_func.return_value = mock_get_profile 36 | request = RequestFactory().get("/.well-known/webfinger?resource=acct:Foobar@example.com") 37 | try: 38 | rfc7033_webfinger_view(request) 39 | except Exception: 40 | pass 41 | mock_get_profile.assert_called_once_with(handle='foobar@example.com', request=request) 42 | 43 | def test_no_resource_returns_bad_request(self): 44 | request = RequestFactory().get("/.well-known/webfinger") 45 | response = rfc7033_webfinger_view(request) 46 | assert response.status_code == 400 47 | 48 | def test_invalid_resource_returns_bad_request(self): 49 | request = RequestFactory().get("/.well-known/webfinger?resource=foobar") 50 | response = rfc7033_webfinger_view(request) 51 | assert response.status_code == 400 52 | 53 | @patch("federation.hostmeta.django.generators.get_function_from_config") 54 | def test_unknown_handle_returns_not_found(self, mock_get_func): 55 | mock_get_func.return_value = Mock(side_effect=Exception) 56 | request = RequestFactory().get("/.well-known/webfinger?resource=acct:foobar@domain.tld") 57 | response = rfc7033_webfinger_view(request) 58 | assert response.status_code == 404 59 | 60 | def test_rendered_webfinger_returned(self): 61 | request = RequestFactory().get("/.well-known/webfinger?resource=acct:foobar@example.com") 62 | response = rfc7033_webfinger_view(request) 63 | assert response.status_code == 200 64 | assert response['Content-Type'] == "application/jrd+json" 65 | assert json.loads(response.content.decode("utf-8")) == { 66 | "subject": "acct:foobar@example.com", 67 | "aliases": [ 68 | "https://example.com/profile/1234/", 69 | "https://example.com/p/1234/", 70 | ], 71 | "links": [ 72 | { 73 | "rel": "http://microformats.org/profile/hcard", 74 | "type": "text/html", 75 | "href": "https://example.com/hcard/users/1234", 76 | }, 77 | { 78 | "rel": "http://joindiaspora.com/seed_location", 79 | "type": "text/html", 80 | "href": "https://example.com", 81 | }, 82 | { 83 | "rel": "http://webfinger.net/rel/profile-page", 84 | "type": "text/html", 85 | "href": "https://example.com/profile/1234/", 86 | }, 87 | { 88 | "rel": "salmon", 89 | "href": "https://example.com/receive/users/1234", 90 | }, 91 | { 92 | "rel": "self", 93 | "href": "https://example.com/p/1234/", 94 | "type": "application/activity+json", 95 | }, 96 | { 97 | "rel": "http://schemas.google.com/g/2010#updates-from", 98 | "type": "application/atom+xml", 99 | "href": "https://example.com/profile/1234/atom.xml", 100 | }, 101 | { 102 | "rel": "http://ostatus.org/schema/1.0/subscribe", 103 | "template": "https://example.com/search?q={uri}", 104 | }, 105 | ], 106 | } 107 | -------------------------------------------------------------------------------- /federation/tests/hostmeta/test_fetchers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import patch 3 | 4 | from federation.hostmeta.fetchers import ( 5 | fetch_nodeinfo_document, fetch_nodeinfo2_document, fetch_statisticsjson_document, fetch_mastodon_document, 6 | fetch_matrix_document) 7 | from federation.tests.fixtures.hostmeta import NODEINFO_WELL_KNOWN_BUGGY, NODEINFO_WELL_KNOWN_BUGGY_2 8 | 9 | 10 | class TestFetchMastodonDocument: 11 | @patch("federation.hostmeta.fetchers.fetch_document", return_value=('{"foo": "bar"}', 200, None), autospec=True) 12 | @patch("federation.hostmeta.fetchers.parse_mastodon_document", autospec=True) 13 | def test_makes_right_calls(self, mock_parse, mock_fetch): 14 | fetch_mastodon_document('example.com') 15 | args, kwargs = mock_fetch.call_args 16 | assert kwargs['host'] == 'example.com' 17 | assert kwargs['path'] == '/api/v1/instance' 18 | mock_parse.assert_called_once_with({"foo": "bar"}, 'example.com') 19 | 20 | 21 | class TestFetchMatrixDocument: 22 | @patch("federation.hostmeta.fetchers.fetch_document", return_value=('{"foo": "bar"}', 200, None), autospec=True) 23 | @patch("federation.hostmeta.fetchers.parse_matrix_document", autospec=True) 24 | def test_makes_right_calls(self, mock_parse, mock_fetch): 25 | fetch_matrix_document('example.com') 26 | args, kwargs = mock_fetch.call_args 27 | assert kwargs['host'] == 'example.com' 28 | assert kwargs['path'] == '/_matrix/federation/v1/version' 29 | mock_parse.assert_called_once_with({"foo": "bar"}, 'example.com') 30 | 31 | 32 | class TestFetchNodeInfoDocument: 33 | dummy_doc = json.dumps({"links": [ 34 | {"href": "https://example.com/1.0", "rel": "http://nodeinfo.diaspora.software/ns/schema/1.0"}, 35 | {"href": "https://example.com/2.0", "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0"}, 36 | {"href": "https://example.com/3.0", "rel": "http://nodeinfo.diaspora.software/ns/schema/3.0"}, 37 | ]}) 38 | 39 | @patch("federation.hostmeta.fetchers.fetch_document", return_value=(dummy_doc, 200, None), autospec=True) 40 | @patch("federation.hostmeta.fetchers.parse_nodeinfo_document", autospec=True) 41 | def test_makes_right_calls(self, mock_parse, mock_fetch): 42 | fetch_nodeinfo_document('example.com') 43 | args, kwargs = mock_fetch.call_args_list[0] 44 | assert kwargs['host'] == 'example.com' 45 | assert kwargs['path'] == '/.well-known/nodeinfo' 46 | args, kwargs = mock_fetch.call_args_list[1] 47 | assert kwargs['url'] == 'https://example.com/2.0' 48 | mock_parse.assert_called_once_with(json.loads(self.dummy_doc), 'example.com') 49 | 50 | @patch("federation.hostmeta.fetchers.fetch_document", 51 | return_value=(NODEINFO_WELL_KNOWN_BUGGY, 200, None), autospec=True) 52 | @patch("federation.hostmeta.fetchers.parse_nodeinfo_document", autospec=True) 53 | def test_makes_right_calls__buggy_nodeinfo_wellknown(self, mock_parse, mock_fetch): 54 | fetch_nodeinfo_document('example.com') 55 | args, kwargs = mock_fetch.call_args_list[0] 56 | assert kwargs['host'] == 'example.com' 57 | assert kwargs['path'] == '/.well-known/nodeinfo' 58 | args, kwargs = mock_fetch.call_args_list[1] 59 | assert kwargs['url'] == 'https://example.com/nodeinfo/2.0' 60 | 61 | @patch("federation.hostmeta.fetchers.fetch_document", 62 | return_value=(NODEINFO_WELL_KNOWN_BUGGY_2, 200, None), autospec=True) 63 | @patch("federation.hostmeta.fetchers.parse_nodeinfo_document", autospec=True) 64 | def test_makes_right_calls__buggy_nodeinfo_wellknown_2(self, mock_parse, mock_fetch): 65 | fetch_nodeinfo_document('example.com') 66 | args, kwargs = mock_fetch.call_args_list[0] 67 | assert kwargs['host'] == 'example.com' 68 | assert kwargs['path'] == '/.well-known/nodeinfo' 69 | args, kwargs = mock_fetch.call_args_list[1] 70 | assert kwargs['url'] == 'https://example.com/nodeinfo/1.0' 71 | 72 | 73 | class TestFetchNodeInfo2Document: 74 | @patch("federation.hostmeta.fetchers.fetch_document", return_value=('{"foo": "bar"}', 200, None), autospec=True) 75 | @patch("federation.hostmeta.fetchers.parse_nodeinfo2_document", autospec=True) 76 | def test_makes_right_calls(self, mock_parse, mock_fetch): 77 | fetch_nodeinfo2_document('example.com') 78 | args, kwargs = mock_fetch.call_args 79 | assert kwargs['host'] == 'example.com' 80 | assert kwargs['path'] == '/.well-known/x-nodeinfo2' 81 | mock_parse.assert_called_once_with({"foo": "bar"}, 'example.com') 82 | 83 | 84 | class TestFetchStatisticsJSONDocument: 85 | @patch("federation.hostmeta.fetchers.fetch_document", return_value=('{"foo": "bar"}', 200, None), autospec=True) 86 | @patch("federation.hostmeta.fetchers.parse_statisticsjson_document", autospec=True) 87 | def test_makes_right_calls(self, mock_parse, mock_fetch): 88 | fetch_statisticsjson_document('example.com') 89 | args, kwargs = mock_fetch.call_args 90 | assert kwargs['host'] == 'example.com' 91 | assert kwargs['path'] == '/statistics.json' 92 | mock_parse.assert_called_once_with({"foo": "bar"}, 'example.com') 93 | -------------------------------------------------------------------------------- /federation/tests/protocols/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/protocols/__init__.py -------------------------------------------------------------------------------- /federation/tests/protocols/activitypub/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/protocols/activitypub/__init__.py -------------------------------------------------------------------------------- /federation/tests/protocols/activitypub/test_protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from federation.protocols.activitypub.protocol import identify_request, identify_id 4 | from federation.types import RequestType 5 | 6 | 7 | def test_identify_id(): 8 | assert identify_id('foobar') is False 9 | assert identify_id('foobar@example.com') is False 10 | assert identify_id('foobar@example.com:8000') is False 11 | assert identify_id('http://foobar@example.com') is True 12 | assert identify_id('https://foobar@example.com') is True 13 | 14 | 15 | class TestIdentifyRequest: 16 | def test_identifies_activitypub_request(self): 17 | assert identify_request(RequestType(body=json.dumps('{"@context": "foo"}'))) 18 | assert identify_request(RequestType(body=json.dumps('{"@context": "foo"}').encode('utf-8'))) 19 | 20 | def test_passes_gracefully_for_non_activitypub_request(self): 21 | assert not identify_request(RequestType(body='foo')) 22 | assert not identify_request(RequestType(body='')) 23 | assert not identify_request(RequestType(body=b'')) 24 | -------------------------------------------------------------------------------- /federation/tests/protocols/activitypub/test_signing.py: -------------------------------------------------------------------------------- 1 | from federation.protocols.activitypub.signing import get_http_authentication 2 | from federation.tests.fixtures.keys import get_dummy_private_key 3 | 4 | 5 | def test_signing_request(): 6 | key = get_dummy_private_key() 7 | auth = get_http_authentication(key, "dummy_key_id") 8 | assert auth.header_signer.headers == [ 9 | '(request-target)', 10 | 'user-agent', 11 | 'host', 12 | 'date', 13 | 'digest', 14 | ] 15 | assert auth.header_signer.secret == key.exportKey() 16 | assert 'dummy_key_id' in auth.header_signer.signature_template 17 | 18 | -------------------------------------------------------------------------------- /federation/tests/protocols/diaspora/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/protocols/diaspora/__init__.py -------------------------------------------------------------------------------- /federation/tests/protocols/diaspora/test_encrypted.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock 2 | 3 | from Crypto.Cipher import AES 4 | from lxml import etree 5 | 6 | from federation.protocols.diaspora.encrypted import pkcs7_unpad, EncryptedPayload 7 | from federation.tests.fixtures.keys import get_dummy_private_key 8 | 9 | 10 | def test_pkcs7_unpad(): 11 | assert pkcs7_unpad(b"foobar\x02\x02") == b"foobar" 12 | assert pkcs7_unpad("foobar\x02\x02") == "foobar" 13 | 14 | 15 | class TestEncryptedPayload: 16 | @patch("federation.protocols.diaspora.encrypted.PKCS1_v1_5.new") 17 | @patch("federation.protocols.diaspora.encrypted.AES.new") 18 | @patch("federation.protocols.diaspora.encrypted.pkcs7_unpad", side_effect=lambda x: x) 19 | @patch("federation.protocols.diaspora.encrypted.b64decode", side_effect=lambda x: x) 20 | def test_decrypt(self, mock_decode, mock_unpad, mock_aes, mock_pkcs1): 21 | mock_decrypt = Mock(return_value=b'{"iv": "foo", "key": "bar"}') 22 | mock_pkcs1.return_value = Mock(decrypt=mock_decrypt) 23 | mock_encrypter = Mock(return_value="bar") 24 | mock_aes.return_value = Mock(decrypt=mock_encrypter) 25 | doc = EncryptedPayload.decrypt( 26 | {"aes_key": '{"iv": "foo", "key": "bar"}', "encrypted_magic_envelope": "magically encrypted"}, 27 | "private_key", 28 | ) 29 | mock_pkcs1.assert_called_once_with("private_key") 30 | mock_decrypt.assert_called_once_with('{"iv": "foo", "key": "bar"}', sentinel=None) 31 | assert mock_decode.call_count == 4 32 | mock_aes.assert_called_once_with("bar", AES.MODE_CBC, "foo") 33 | mock_encrypter.assert_called_once_with("magically encrypted") 34 | assert doc.tag == "foo" 35 | assert doc.text == "bar" 36 | 37 | def test_encrypt(self): 38 | private_key = get_dummy_private_key() 39 | public_key = private_key.publickey() 40 | encrypted = EncryptedPayload.encrypt("eggs", public_key) 41 | assert "aes_key" in encrypted 42 | assert "encrypted_magic_envelope" in encrypted 43 | # See we can decrypt it too 44 | decrypted = EncryptedPayload.decrypt(encrypted, private_key) 45 | assert etree.tostring(decrypted).decode("utf-8") == "eggs" 46 | -------------------------------------------------------------------------------- /federation/tests/protocols/diaspora/test_magic_envelope.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock 2 | 3 | import pytest 4 | from lxml import etree 5 | from lxml.etree import _Element 6 | 7 | from federation.exceptions import SignatureVerificationError 8 | from federation.protocols.diaspora.magic_envelope import MagicEnvelope 9 | from federation.tests.fixtures.keys import get_dummy_private_key, PUBKEY 10 | from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD 11 | 12 | 13 | class TestMagicEnvelope: 14 | def test_build(self): 15 | env = MagicEnvelope( 16 | message="bar", 17 | private_key=get_dummy_private_key(), 18 | author_handle="foobar@example.com" 19 | ) 20 | doc = env.build() 21 | assert isinstance(doc, _Element) 22 | 23 | def test_create_payload(self): 24 | env = MagicEnvelope( 25 | message="bar", 26 | private_key="key", 27 | author_handle="foobar@example.com", 28 | ) 29 | payload = env.create_payload() 30 | assert payload == "PHN0YXR1c19tZXNzYWdlPjxmb28-YmFyPC9mb28-PC9zdGF0dXNfbWVzc2FnZT4=" 31 | 32 | def test_extract_payload(self, diaspora_public_payload): 33 | env = MagicEnvelope() 34 | env.payload = diaspora_public_payload 35 | assert not env.doc 36 | assert not env.author_handle 37 | assert not env.message 38 | env.extract_payload() 39 | assert isinstance(env.doc, _Element) 40 | assert env.author_handle == "foobar@example.com" 41 | assert env.message == b"bar" 42 | 43 | @patch("federation.protocols.diaspora.magic_envelope.fetch_public_key", autospec=True) 44 | def test_fetch_public_key__calls_sender_key_fetcher(self, mock_fetch): 45 | mock_fetcher = Mock(return_value="public key") 46 | env = MagicEnvelope(author_handle="spam@eggs", sender_key_fetcher=mock_fetcher) 47 | env.fetch_public_key() 48 | mock_fetcher.assert_called_once_with("spam@eggs") 49 | assert not mock_fetch.called 50 | 51 | @patch("federation.protocols.diaspora.magic_envelope.fetch_public_key", autospec=True) 52 | def test_fetch_public_key__calls_fetch_public_key(self, mock_fetch): 53 | env = MagicEnvelope(author_handle="spam@eggs") 54 | env.fetch_public_key() 55 | mock_fetch.assert_called_once_with("spam@eggs") 56 | 57 | def test_message_from_doc(self, diaspora_public_payload): 58 | env = MagicEnvelope(payload=diaspora_public_payload) 59 | assert env.message_from_doc() == env.message 60 | 61 | def test_payload_extracted_on_init(self, diaspora_public_payload): 62 | env = MagicEnvelope(payload=diaspora_public_payload) 63 | assert isinstance(env.doc, _Element) 64 | assert env.author_handle == "foobar@example.com" 65 | assert env.message == b"bar" 66 | 67 | def test_verify(self, private_key, public_key): 68 | me = MagicEnvelope( 69 | message="bar", 70 | private_key=private_key, 71 | author_handle="foobar@example.com" 72 | ) 73 | me.build() 74 | output = me.render() 75 | 76 | MagicEnvelope(payload=output, public_key=public_key, verify=True) 77 | 78 | with pytest.raises(SignatureVerificationError): 79 | MagicEnvelope(payload=output, public_key=PUBKEY, verify=True) 80 | 81 | def test_verify__calls_fetch_public_key(self, diaspora_public_payload): 82 | me = MagicEnvelope(payload=diaspora_public_payload) 83 | with pytest.raises(TypeError): 84 | with patch.object(me, "fetch_public_key") as mock_fetch: 85 | me.verify() 86 | mock_fetch.assert_called_once_with() 87 | 88 | @patch("federation.protocols.diaspora.magic_envelope.MagicEnvelope.verify") 89 | def test_verify_on_init(self, mock_verify, diaspora_public_payload): 90 | MagicEnvelope(payload=diaspora_public_payload) 91 | assert not mock_verify.called 92 | MagicEnvelope(payload=diaspora_public_payload, verify=True) 93 | assert mock_verify.called 94 | 95 | def test_build_signature(self): 96 | env = MagicEnvelope( 97 | message="bar", 98 | private_key=get_dummy_private_key(), 99 | author_handle="foobar@example.com" 100 | ) 101 | env.create_payload() 102 | signature, key_id = env._build_signature() 103 | assert signature == b'Cmk08MR4Tp8r9eVybD1hORcR_8NLRVxAu0biOfJbkI1xLx1c480zJ720cpVyKaF9CxVjW3lvlvRz' \ 104 | b'5YbswMv0izPzfHpXoWTXH-4UPrXaGYyJnrNvqEB2UWn4iHKJ2Rerto8sJY2b95qbXD6Nq75EoBNu' \ 105 | b'b5P7DYc16ENhp38YwBRnrBEvNOewddpOpEBVobyNB7no_QR8c_xkXie-hUDFNwI0z7vax9HkaBFb' \ 106 | b'vEmzFPMZAAdWyjxeGiWiqY0t2ZdZRCPTezy66X6Q0qc4I8kfT-Mt1ctjGmNMoJ4Lgu-PrO5hSRT4' \ 107 | b'QBAVyxaog5w-B0PIPuC-mUW5SZLsnX3_ZuwJww==' 108 | assert key_id == b"Zm9vYmFyQGV4YW1wbGUuY29t" 109 | 110 | def test_render(self): 111 | env = MagicEnvelope( 112 | message="bar", 113 | private_key=get_dummy_private_key(), 114 | author_handle="foobar@example.com" 115 | ) 116 | env.build() 117 | output = env.render() 118 | assert output == 'base64url' \ 119 | 'RSA-SHA256' \ 120 | 'PHN0YXR1c19tZXNzYWdlPjxmb28-YmFyPC9mb28-PC9zdGF0dXNfbWVzc2FnZT4=' \ 121 | 'Cmk08MR4Tp8r9eVybD1hORcR_8NLRVxAu0biOfJbk' \ 122 | 'I1xLx1c480zJ720cpVyKaF9CxVjW3lvlvRz5YbswMv0izPzfHpXoWTXH-4UPrXaGYyJnrNvqEB2UWn4iHK' \ 123 | 'J2Rerto8sJY2b95qbXD6Nq75EoBNub5P7DYc16ENhp38YwBRnrBEvNOewddpOpEBVobyNB7no_QR8c_xkX' \ 124 | 'ie-hUDFNwI0z7vax9HkaBFbvEmzFPMZAAdWyjxeGiWiqY0t2ZdZRCPTezy66X6Q0qc4I8kfT-Mt1ctjGmNM' \ 125 | 'oJ4Lgu-PrO5hSRT4QBAVyxaog5w-B0PIPuC-mUW5SZLsnX3_ZuwJww==' 126 | env2 = MagicEnvelope( 127 | message="bar", 128 | private_key=get_dummy_private_key(), 129 | author_handle="foobar@example.com" 130 | ) 131 | output2 = env2.render() 132 | assert output2 == output 133 | 134 | def test_get_sender(self): 135 | doc = etree.fromstring(bytes(DIASPORA_PUBLIC_PAYLOAD, encoding="utf-8")) 136 | assert MagicEnvelope.get_sender(doc) == "foobar@example.com" 137 | -------------------------------------------------------------------------------- /federation/tests/protocols/diaspora/test_signatures.py: -------------------------------------------------------------------------------- 1 | from lxml import etree 2 | 3 | from federation.protocols.diaspora.signatures import create_relayable_signature, verify_relayable_signature 4 | from federation.tests.fixtures.keys import PUBKEY, SIGNATURE, SIGNATURE2, SIGNATURE3, XML, XML2, get_dummy_private_key 5 | 6 | 7 | def test_verify_relayable_signature(): 8 | doc = etree.XML(XML) 9 | assert verify_relayable_signature(PUBKEY, doc, SIGNATURE) 10 | 11 | 12 | def test_verify_relayable_signature_with_unicode(): 13 | doc = etree.XML(XML2) 14 | assert verify_relayable_signature(PUBKEY, doc, SIGNATURE2) 15 | 16 | 17 | def test_create_relayable_signature(): 18 | doc = etree.XML(XML) 19 | signature = create_relayable_signature(get_dummy_private_key(), doc) 20 | assert signature == SIGNATURE3 21 | -------------------------------------------------------------------------------- /federation/tests/protocols/matrix/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/tests/protocols/matrix/__init__.py -------------------------------------------------------------------------------- /federation/tests/protocols/matrix/test_appservice.py: -------------------------------------------------------------------------------- 1 | from federation.protocols.matrix.appservice import get_registration_config, print_registration_yaml 2 | 3 | 4 | def test_get_registration(): 5 | config = get_registration_config() 6 | assert config == { 7 | "id": "uniqueid", 8 | "url": "https://example.com/matrix", 9 | "as_token": "secret_token", 10 | "hs_token": "secret_token", 11 | "sender_localpart": "_myawesomeapp", 12 | "namespaces": { 13 | "users": [ 14 | { 15 | "exclusive": False, 16 | "regex": "@.*", 17 | }, 18 | { 19 | "exclusive": True, 20 | "regex": "@_myawesomeapp_.*", 21 | }, 22 | ], 23 | "aliases": [ 24 | { 25 | "exclusive": False, 26 | "regex": "#.*", 27 | }, 28 | { 29 | "exclusive": True, 30 | "regex": "#_myawesomeapp_.*", 31 | }, 32 | ], 33 | "rooms": [], 34 | } 35 | } 36 | 37 | 38 | def test_print_registration_yaml(): 39 | """ 40 | Just execute and ensure doesn't crash. 41 | """ 42 | print_registration_yaml() 43 | -------------------------------------------------------------------------------- /federation/tests/protocols/matrix/test_protocol.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from federation.protocols.matrix.protocol import identify_request, identify_id 4 | from federation.types import RequestType 5 | 6 | 7 | def test_identify_id(): 8 | assert identify_id('foobar') is False 9 | assert identify_id('foobar@example.com') is False 10 | assert identify_id('foobar@example.com:8000') is False 11 | assert identify_id('http://foobar@example.com') is False 12 | assert identify_id('https://foobar@example.com') is False 13 | assert identify_id('@foobar:domain.tld') is True 14 | assert identify_id('#foobar:domain.tld') is True 15 | assert identify_id('!foobar:domain.tld') is True 16 | 17 | 18 | class TestIdentifyRequest: 19 | def test_identifies_matrix_request(self): 20 | assert identify_request(RequestType(body=json.dumps('{"events": []}'))) 21 | assert identify_request(RequestType(body=json.dumps('{"events": []}').encode('utf-8'))) 22 | 23 | def test_passes_gracefully_for_non_matrix_request(self): 24 | assert not identify_request(RequestType(body='foo')) 25 | assert not identify_request(RequestType(body='')) 26 | assert not identify_request(RequestType(body=b'')) 27 | assert not identify_request(RequestType(body=json.dumps('{"@context": "foo"}'))) 28 | assert not identify_request(RequestType(body=json.dumps('{"@context": "foo"}').encode('utf-8'))) 29 | -------------------------------------------------------------------------------- /federation/tests/test_fetchers.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch, Mock, call 2 | 3 | from federation.fetchers import retrieve_remote_profile, retrieve_remote_content 4 | 5 | 6 | class TestRetrieveRemoteContent: 7 | @patch("federation.fetchers.importlib.import_module") 8 | def test_calls_activitypub_retrieve_and_parse_content(self, mock_import): 9 | mock_retrieve = Mock() 10 | mock_import.return_value = mock_retrieve 11 | retrieve_remote_content("https://example.com/foobar") 12 | mock_retrieve.retrieve_and_parse_content.assert_called_once_with( 13 | id="https://example.com/foobar", guid=None, handle=None, entity_type=None, cache=True, sender_key_fetcher=None, 14 | ) 15 | 16 | @patch("federation.fetchers.importlib.import_module") 17 | def test_calls_diaspora_retrieve_and_parse_content(self, mock_import): 18 | mock_retrieve = Mock() 19 | mock_import.return_value = mock_retrieve 20 | retrieve_remote_content("1234", handle="user@example.com", entity_type="post", sender_key_fetcher=sum) 21 | mock_retrieve.retrieve_and_parse_content.assert_called_once_with( 22 | id="1234", guid="1234", handle="user@example.com", entity_type="post", cache=True, sender_key_fetcher=sum, 23 | ) 24 | 25 | 26 | class TestRetrieveRemoteProfile: 27 | @patch("federation.fetchers.importlib.import_module", autospec=True) 28 | @patch("federation.fetchers.validate_handle", autospec=True, return_value=False) 29 | @patch("federation.fetchers.identify_protocol_by_id", autospec=True, return_value=Mock(PROTOCOL_NAME='activitypub')) 30 | def test_retrieve_remote_profile__url_calls_activitypub_retrieve(self, mock_identify, mock_validate, mock_import): 31 | mock_utils = Mock() 32 | mock_import.return_value = mock_utils 33 | retrieve_remote_profile("https://example.com/foo") 34 | mock_import.assert_called_once_with("federation.utils.activitypub") 35 | mock_utils.retrieve_and_parse_profile.assert_called_once_with("https://example.com/foo") 36 | 37 | @patch("federation.fetchers.importlib.import_module", autospec=True) 38 | @patch("federation.fetchers.validate_handle", autospec=True, return_value=True) 39 | @patch("federation.fetchers.identify_protocol_by_id", autospec=True) 40 | def test_retrieve_remote_profile__handle_calls_both_activitypub_and_diaspora_retrieve( 41 | self, mock_identify, mock_validate, mock_import, 42 | ): 43 | mock_utils = Mock(retrieve_and_parse_profile=Mock(return_value=None)) 44 | mock_import.return_value = mock_utils 45 | retrieve_remote_profile("user@example.com") 46 | calls = [ 47 | call("federation.utils.activitypub"), 48 | call("federation.utils.diaspora"), 49 | ] 50 | assert mock_import.call_args_list == calls 51 | calls = [ 52 | call("user@example.com"), 53 | call("user@example.com"), 54 | ] 55 | assert mock_utils.retrieve_and_parse_profile.call_args_list == calls 56 | -------------------------------------------------------------------------------- /federation/tests/test_inbound.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | 5 | from federation.exceptions import NoSuitableProtocolFoundError 6 | from federation.inbound import handle_receive 7 | from federation.protocols.diaspora.protocol import Protocol 8 | from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD 9 | from federation.types import RequestType 10 | 11 | 12 | class TestHandleReceiveProtocolIdentification: 13 | def test_handle_receive_routes_to_identified_protocol(self): 14 | payload = RequestType(body=DIASPORA_PUBLIC_PAYLOAD) 15 | with patch.object( 16 | Protocol, 17 | 'receive', 18 | return_value=("foobar@domain.tld", "")) as mock_receive,\ 19 | patch( 20 | "federation.entities.diaspora.mappers.message_to_objects", 21 | return_value=[]) as mock_message_to_objects: 22 | handle_receive(payload) 23 | assert mock_receive.called 24 | 25 | def test_handle_receive_raises_on_unidentified_protocol(self): 26 | payload = RequestType(body="foobar") 27 | with pytest.raises(NoSuitableProtocolFoundError): 28 | handle_receive(payload) 29 | -------------------------------------------------------------------------------- /federation/tests/test_outbound.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import Mock, patch 2 | 3 | import pytest 4 | 5 | from federation.entities.diaspora.entities import DiasporaPost 6 | from federation.outbound import handle_create_payload, handle_send 7 | from federation.tests.fixtures.keys import get_dummy_private_key 8 | from federation.types import UserType 9 | from federation.utils.text import encode_if_text 10 | 11 | 12 | class TestHandleCreatePayloadBuildsAPayload: 13 | @patch("federation.protocols.diaspora.protocol.MagicEnvelope", autospec=True) 14 | def test_handle_create_payload___diaspora__calls_magic_envelope_render(self, mock_me): 15 | mock_render = Mock() 16 | mock_me.return_value = Mock(render=mock_render) 17 | author_user = Mock() 18 | entity = DiasporaPost() 19 | entity.validate = Mock() 20 | handle_create_payload(entity, author_user, "diaspora") 21 | mock_render.assert_called_once_with() 22 | 23 | 24 | @patch("federation.outbound.send_document") 25 | class TestHandleSend: 26 | def test_calls_handle_create_payload(self, mock_send, profile): 27 | key = get_dummy_private_key() 28 | recipients = [ 29 | { 30 | "endpoint": "https://127.0.0.1/receive/users/1234", "public_key": key.publickey(), "public": False, 31 | "protocol": "diaspora", "fid": "", 32 | }, 33 | { 34 | "endpoint": "https://example.com/receive/public", "public": True, "protocol": "diaspora", 35 | "fid": "", 36 | }, 37 | { 38 | "endpoint": "https://example.net/receive/public", "public": True, "protocol": "diaspora", 39 | "fid": "", 40 | }, 41 | # Same twice to ensure one delivery only per unique 42 | { 43 | "endpoint": "https://example.net/receive/public", "public": True, "protocol": "diaspora", 44 | "fid": "", 45 | }, 46 | { 47 | "endpoint": "https://example.net/foobar/inbox", "fid": "https://example.net/foobar", "public": False, 48 | "protocol": "activitypub", 49 | }, 50 | { 51 | "endpoint": "https://example.net/inbox", "fid": "https://example.net/foobar", "public": True, 52 | "protocol": "activitypub", 53 | } 54 | ] 55 | author = UserType( 56 | private_key=key, id="foo@example.com", handle="foo@example.com", 57 | ) 58 | handle_send(profile, author, recipients) 59 | 60 | # Ensure first call is a private diaspora payload 61 | args, kwargs = mock_send.call_args_list[0] 62 | assert args[0] == "https://127.0.0.1/receive/users/1234" 63 | assert "aes_key" in args[1] 64 | assert "encrypted_magic_envelope" in args[1] 65 | assert kwargs['headers'] == {'Content-Type': 'application/json'} 66 | 67 | # Ensure second call is a private activitypub payload 68 | args, kwargs = mock_send.call_args_list[1] 69 | assert args[0] == "https://example.net/foobar/inbox" 70 | assert kwargs['headers'] == { 71 | 'Content-Type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 72 | } 73 | # not sure what the use case is of having both public and private recipients for a single 74 | # handle_send call 75 | #assert encode_if_text("https://www.w3.org/ns/activitystreams#Public") not in args[1] 76 | 77 | # Ensure third call is a public activitypub payload 78 | args, kwargs = mock_send.call_args_list[2] 79 | assert args[0] == "https://example.net/inbox" 80 | assert kwargs['headers'] == { 81 | 'Content-Type': 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', 82 | } 83 | assert encode_if_text("https://www.w3.org/ns/activitystreams#Public") in args[1] 84 | 85 | # Ensure diaspora public payloads and recipients, one per unique host 86 | args3, kwargs3 = mock_send.call_args_list[3] 87 | args4, kwargs4 = mock_send.call_args_list[4] 88 | public_endpoints = {args3[0], args4[0]} 89 | assert public_endpoints == { 90 | "https://example.net/receive/public", 91 | "https://example.com/receive/public", 92 | } 93 | assert args3[1].startswith(" -1: 38 | raise HTTPError() 39 | return Mock(status_code=200, text="foo") 40 | mock_get.side_effect = mock_failing_https_get 41 | fetch_document(host="localhost") 42 | assert mock_get.call_count == 2 43 | assert mock_get.call_args_list == [ 44 | call("https://localhost/", **self.call_args), 45 | call("http://localhost/", **self.call_args), 46 | ] 47 | 48 | @patch("federation.utils.network.session.get") 49 | def test_host_is_sanitized(self, mock_get): 50 | mock_get.return_value = Mock(status_code=200, text="foo") 51 | fetch_document(host="http://localhost") 52 | assert mock_get.call_args_list == [ 53 | call("https://localhost/", **self.call_args) 54 | ] 55 | 56 | @patch("federation.utils.network.session.get") 57 | def test_path_is_sanitized(self, mock_get): 58 | mock_get.return_value = Mock(status_code=200, text="foo") 59 | fetch_document(host="localhost", path="foobar/bazfoo") 60 | assert mock_get.call_args_list == [ 61 | call("https://localhost/foobar/bazfoo", **self.call_args) 62 | ] 63 | 64 | @patch("federation.utils.network.session.get") 65 | def test_exception_is_raised_if_both_protocols_fail(self, mock_get): 66 | mock_get.side_effect = HTTPError 67 | doc, code, exc = fetch_document(host="localhost") 68 | assert mock_get.call_count == 2 69 | assert doc == None 70 | assert code == None 71 | assert exc.__class__ == HTTPError 72 | 73 | @patch("federation.utils.network.session.get") 74 | def test_exception_is_raised_if_url_fails(self, mock_get): 75 | mock_get.side_effect = HTTPError 76 | doc, code, exc = fetch_document("localhost") 77 | assert mock_get.call_count == 1 78 | assert doc == None 79 | assert code == None 80 | assert exc.__class__ == HTTPError 81 | 82 | @patch("federation.utils.network.session.get") 83 | def test_exception_is_raised_if_http_fails_and_raise_ssl_errors_true(self, mock_get): 84 | mock_get.side_effect = SSLError 85 | doc, code, exc = fetch_document("localhost") 86 | assert mock_get.call_count == 1 87 | assert doc == None 88 | assert code == None 89 | assert exc.__class__ == SSLError 90 | 91 | @patch("federation.utils.network.session.get") 92 | def test_exception_is_raised_on_network_error(self, mock_get): 93 | mock_get.side_effect = RequestException 94 | doc, code, exc = fetch_document(host="localhost") 95 | assert mock_get.call_count == 1 96 | assert doc == None 97 | assert code == None 98 | assert exc.__class__ == RequestException 99 | 100 | 101 | class TestFetchHostIp: 102 | @patch('federation.utils.network.socket.gethostbyname', autospec=True, return_value='127.0.0.1') 103 | def test_calls(self, mock_get_ip): 104 | result = fetch_host_ip('domain.local') 105 | assert result == '127.0.0.1' 106 | mock_get_ip.assert_called_once_with('domain.local') 107 | 108 | 109 | class TestSendDocument: 110 | call_args = {"timeout": 10, "headers": {'user-agent': USER_AGENT}} 111 | 112 | @patch("federation.utils.network.requests.post", return_value=Mock(status_code=200)) 113 | def test_post_is_called(self, mock_post): 114 | code, exc = send_document("http://localhost", {"foo": "bar"}) 115 | mock_post.assert_called_once_with( 116 | "http://localhost", data={"foo": "bar"}, **self.call_args 117 | ) 118 | assert code == 200 119 | assert exc == None 120 | 121 | @patch("federation.utils.network.requests.post", side_effect=RequestException) 122 | def test_post_raises_and_returns_exception(self, mock_post): 123 | code, exc = send_document("http://localhost", {"foo": "bar"}) 124 | assert code == None 125 | assert exc.__class__ == RequestException 126 | 127 | @patch("federation.utils.network.requests.post", return_value=Mock(status_code=200)) 128 | def test_post_called_with_only_one_headers_kwarg(self, mock_post): 129 | # A failure might raise: 130 | # TypeError: MagicMock object got multiple values for keyword argument 'headers' 131 | send_document("http://localhost", {"foo": "bar"}, **self.call_args) 132 | mock_post.assert_called_once_with( 133 | "http://localhost", data={"foo": "bar"}, **self.call_args 134 | ) 135 | 136 | @patch("federation.utils.network.requests.post", return_value=Mock(status_code=200)) 137 | def test_headers_in_either_case_are_handled_without_exception(self, mock_post): 138 | send_document("http://localhost", {"foo": "bar"}, **self.call_args) 139 | mock_post.assert_called_once_with( 140 | "http://localhost", data={"foo": "bar"}, headers={'user-agent': USER_AGENT}, timeout=10 141 | ) 142 | mock_post.reset_mock() 143 | send_document("http://localhost", {"foo": "bar"}, headers={'User-Agent': USER_AGENT}) 144 | mock_post.assert_called_once_with( 145 | "http://localhost", data={"foo": "bar"}, headers={'User-Agent': USER_AGENT}, timeout=10 146 | ) 147 | -------------------------------------------------------------------------------- /federation/tests/utils/test_protocol.py: -------------------------------------------------------------------------------- 1 | from federation.utils.protocols import identify_recipient_protocol 2 | 3 | 4 | def test_identify_recipient_protocol(): 5 | assert identify_recipient_protocol("https://example.com/foo") == "activitypub" 6 | assert identify_recipient_protocol("http://example.com/foo") == "activitypub" 7 | assert identify_recipient_protocol("http://127.0.0.1/foo") == "activitypub" 8 | assert identify_recipient_protocol("http://localhost/foo") == "activitypub" 9 | assert identify_recipient_protocol("ftp://example.com/foo") is None 10 | assert identify_recipient_protocol("foo@example.com") == "diaspora" 11 | assert identify_recipient_protocol("foo@127.0.0.1") == "diaspora" 12 | assert identify_recipient_protocol("foo@localhost") is None 13 | assert identify_recipient_protocol("@foo@example.com") is None 14 | assert identify_recipient_protocol("@foo:example.com") is None 15 | -------------------------------------------------------------------------------- /federation/tests/utils/test_text.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from federation.utils.text import decode_if_bytes, encode_if_text, validate_handle, find_tags 4 | 5 | 6 | def test_decode_if_bytes(): 7 | assert decode_if_bytes(b"foobar") == "foobar" 8 | assert decode_if_bytes("foobar") == "foobar" 9 | 10 | 11 | def test_encode_if_text(): 12 | assert encode_if_text(b"foobar") == b"foobar" 13 | assert encode_if_text("foobar") == b"foobar" 14 | 15 | 16 | class TestFindTags: 17 | @staticmethod 18 | def _replacer(text): 19 | return f"#{text}/{text.lower()}" 20 | 21 | def test_all_tags_are_parsed_from_text(self): 22 | source = "#starting and #MixED with some #line\nendings also tags can\n#start on new line" 23 | tags = find_tags(source) 24 | assert tags == {"starting", "mixed", "line", "start"} 25 | 26 | def test_code_block_tags_ignored(self): 27 | source = "foo\n```\n#code\n```\n#notcode\n\n #alsocode\n" 28 | tags = find_tags(source) 29 | assert tags == {"notcode"} 30 | 31 | def test_endings_are_filtered_out(self): 32 | source = "#parenthesis) #exp! #list] *#doh* _#bah_ #gah% #foo/#bar" 33 | tags = find_tags(source) 34 | assert tags == {"parenthesis", "exp", "list", "doh", "bah", "gah", "foo", "bar"} 35 | 36 | def test_finds_tags(self): 37 | source = "#post **Foobar** #tag #OtherTag #third\n#fourth" 38 | tags = find_tags(source) 39 | assert tags == {"third", "fourth", "post", "othertag", "tag"} 40 | 41 | def test_ok_with_html_tags_in_text(self): 42 | source = "

#starting and #MixED however not <#>this or <#/>that" 43 | tags = find_tags(source) 44 | assert tags == {"starting", "mixed"} 45 | 46 | def test_postfixed_tags(self): 47 | source = "#foo) #bar] #hoo, #hee." 48 | tags = find_tags(source) 49 | assert tags == {"foo", "bar", "hoo", "hee"} 50 | 51 | def test_prefixed_tags(self): 52 | source = "(#foo [#bar" 53 | tags = find_tags(source) 54 | assert tags == {"foo", "bar"} 55 | 56 | def test_invalid_text_returns_no_tags(self): 57 | source = "#a!a #a#a #a$a #a%a #a^a #a&a #a*a #a+a #a.a #a,a #a@a #a£a #a(a #a)a #a=a " \ 58 | "#a?a #a`a #a'a #a\\a #a{a #a[a #a]a #a}a #a~a #a;a #a:a #a\"a #a’a #a”a #\xa0cd" 59 | tags = find_tags(source) 60 | assert tags == {'a'} 61 | 62 | def test_start_of_paragraph_in_html_content(self): 63 | source = '

First line

#foobar #barfoo

' 64 | tags = find_tags(source) 65 | assert tags == {"foobar", "barfoo"} 66 | 67 | 68 | def test_validate_handle(): 69 | assert validate_handle("foo@bar.com") 70 | assert validate_handle("Foo@baR.com") 71 | assert validate_handle("foo@foo.bar.com") 72 | assert validate_handle("foo@bar.com:3000") 73 | assert not validate_handle("@bar.com") 74 | assert not validate_handle("foo@b/ar.com") 75 | assert not validate_handle("foo@bar") 76 | assert not validate_handle("fo/o@bar.com") 77 | assert not validate_handle("foobar.com") 78 | assert not validate_handle("foo@bar,com") 79 | -------------------------------------------------------------------------------- /federation/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Optional, Dict, Union 3 | 4 | import attr 5 | # noinspection PyPackageRequirements 6 | from Crypto.PublicKey import RSA 7 | # noinspection PyPackageRequirements 8 | from Crypto.PublicKey.RSA import RsaKey 9 | 10 | 11 | @attr.s 12 | class RequestType: 13 | """ 14 | Emulates structure of a Django HttpRequest for compatibility. 15 | """ 16 | body: Union[str, bytes] = attr.ib() 17 | 18 | # Required when dealing with incoming AP payloads 19 | headers: Dict = attr.ib(default=None) 20 | method: str = attr.ib(default=None) 21 | url: str = attr.ib(default=None) 22 | 23 | 24 | class ReceiverVariant(Enum): 25 | # Indicates this receiver is a single actor 26 | ACTOR = "actor" 27 | # Indicates this receiver is the followers of this actor 28 | FOLLOWERS = "followers" 29 | 30 | 31 | # TODO needed? 32 | class UserVariant(Enum): 33 | """ 34 | Indicates whether the user is local or remote. 35 | """ 36 | LOCAL = "local" 37 | REMOTE = "remote" 38 | 39 | 40 | @attr.s(frozen=True) 41 | class UserType: 42 | id: str = attr.ib() 43 | private_key: Optional[Union[RsaKey, str]] = attr.ib(default=None) 44 | receiver_variant: Optional[ReceiverVariant] = attr.ib(default=None) 45 | 46 | # Required only if sending to Diaspora protocol platforms 47 | handle: Optional[str] = attr.ib(default=None) 48 | guid: Optional[str] = attr.ib(default=None) 49 | 50 | # Required only if sending to Matrix protocol 51 | mxid: Optional[str] = attr.ib(default=None) 52 | # TODO needed? 53 | variant: Optional[UserVariant] = attr.ib(default=None) 54 | 55 | @property 56 | def rsa_private_key(self) -> RsaKey: 57 | if isinstance(self.private_key, str): 58 | return RSA.importKey(self.private_key) 59 | return self.private_key 60 | -------------------------------------------------------------------------------- /federation/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaywink/federation/e29d7bff0ebea60e73d8b9bd79ac51363046d673/federation/utils/__init__.py -------------------------------------------------------------------------------- /federation/utils/activitypub.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import re 4 | from typing import Optional, Any 5 | from urllib.parse import urlparse 6 | 7 | from federation.protocols.activitypub.signing import get_http_authentication 8 | from federation.utils.network import fetch_document, try_retrieve_webfinger_document 9 | from federation.utils.text import decode_if_bytes, validate_handle 10 | 11 | logger = logging.getLogger('federation') 12 | 13 | try: 14 | from federation.utils.django import get_federation_user 15 | federation_user = get_federation_user() 16 | except Exception as exc: 17 | federation_user = None 18 | logger.warning("django is required for get requests signing: %s", exc) 19 | 20 | type_path = re.compile(r'^application/(activity|ld)\+json') 21 | 22 | 23 | def get_profile_id_from_webfinger(handle: str) -> Optional[str]: 24 | """ 25 | Fetch remote webfinger, if any, and try to parse an AS2 profile ID. 26 | """ 27 | document = try_retrieve_webfinger_document(handle) 28 | if not document: 29 | return 30 | 31 | try: 32 | doc = json.loads(document) 33 | except json.JSONDecodeError: 34 | return 35 | for link in doc.get("links", []): 36 | if link.get("rel") == "self" and type_path.match(link.get("type")): 37 | return link["href"] 38 | logger.debug("get_profile_id_from_webfinger: found webfinger but it has no as2 self href") 39 | 40 | 41 | def get_profile_finger_from_webfinger(fid: str) -> Optional[str]: 42 | """ 43 | Fetch remote webfinger subject acct (finger) using AS2 profile ID 44 | """ 45 | document = try_retrieve_webfinger_document(fid) 46 | if not document: 47 | return 48 | 49 | try: 50 | doc = json.loads(document) 51 | except json.JSONDecodeError: 52 | return 53 | 54 | finger = '' if not isinstance(doc, dict) else doc.get('subject', '').replace('acct:', '') 55 | return finger if validate_handle(finger) else None 56 | 57 | 58 | def retrieve_and_parse_content(**kwargs) -> Optional[Any]: 59 | return retrieve_and_parse_document(kwargs.get("id"), cache=kwargs.get('cache',True)) 60 | 61 | 62 | def retrieve_and_parse_document(fid: str, cache: bool=True) -> Optional[Any]: 63 | """ 64 | Retrieve remote document by ID and return the entity. 65 | """ 66 | from federation.entities.activitypub.models import element_to_objects # Circulars 67 | extra_headers={'accept': 'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"'} 68 | auth=get_http_authentication(federation_user.rsa_private_key, 69 | f'{federation_user.id}#main-key', 70 | digest=False) if federation_user else None 71 | document, status_code, ex = fetch_document(fid, 72 | extra_headers=extra_headers, 73 | cache=cache, 74 | auth=auth) 75 | if document: 76 | try: 77 | document = json.loads(decode_if_bytes(document)) 78 | except json.decoder.JSONDecodeError: 79 | return None 80 | entities = element_to_objects(document) 81 | if entities: 82 | entity = entities[0] 83 | id = entity.id or entity.activity_id 84 | # check against potential payload forgery (CVE-2024-23832) 85 | if urlparse(id).netloc != urlparse(fid).netloc: 86 | logger.warning('retrieve_and_parse_document - payload may be forged, discarding: %s', fid) 87 | return None 88 | logger.info("retrieve_and_parse_document - using first entity: %s", entity) 89 | return entity 90 | 91 | 92 | def retrieve_and_parse_profile(fid: str) -> Optional[Any]: 93 | """ 94 | Retrieve the remote fid and return a Profile object. 95 | """ 96 | if validate_handle(fid): 97 | profile_id = get_profile_id_from_webfinger(fid) 98 | if not profile_id: 99 | return 100 | else: 101 | profile_id = fid 102 | profile = retrieve_and_parse_document(profile_id) 103 | if not profile: 104 | return 105 | try: 106 | profile.validate() 107 | except ValueError as ex: 108 | logger.warning("retrieve_and_parse_profile - found profile %s but it didn't validate: %s", 109 | profile, ex) 110 | return 111 | return profile 112 | 113 | -------------------------------------------------------------------------------- /federation/utils/django.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import redis 3 | from requests_cache import RedisCache, SQLiteCache 4 | 5 | from django.conf import settings 6 | from django.core.exceptions import ImproperlyConfigured 7 | from federation.types import UserType 8 | 9 | 10 | def get_configuration(): 11 | """ 12 | Combine defaults with the Django configuration. 13 | """ 14 | configuration = { 15 | "get_object_function": None, 16 | "hcard_path": "/hcard/users/", 17 | "nodeinfo2_function": None, 18 | "process_payload_function": None, 19 | "search_path": None, 20 | "tags_path": None, 21 | # TODO remove or default to True once AP support is more ready 22 | "activitypub": False, 23 | } 24 | try: 25 | configuration.update(settings.FEDERATION) 26 | except (ModuleNotFoundError, ImproperlyConfigured): 27 | # Django is not properly configured, return defaults 28 | return configuration 29 | if not all([ 30 | "get_private_key_function" in configuration, 31 | "get_profile_function" in configuration, 32 | "base_url" in configuration, 33 | "federation_id" in configuration, 34 | ]): 35 | raise ImproperlyConfigured("Missing required FEDERATION settings, please check documentation.") 36 | return configuration 37 | 38 | 39 | def get_function_from_config(item): 40 | """ 41 | Import the function to get profile by handle. 42 | """ 43 | config = get_configuration() 44 | func_path = config.get(item) 45 | module_path, func_name = func_path.rsplit(".", 1) 46 | module = importlib.import_module(module_path) 47 | func = getattr(module, func_name) 48 | return func 49 | 50 | def get_federation_user(): 51 | config = get_configuration() 52 | if not config.get('federation_id'): return None 53 | 54 | try: 55 | get_key = get_function_from_config("get_private_key_function") 56 | except AttributeError: 57 | return None 58 | 59 | key = get_key(config['federation_id']) 60 | if not key: return None 61 | 62 | return UserType(id=config['federation_id'], private_key=key) 63 | 64 | def get_redis(): 65 | """ 66 | Returns a connected redis object if available 67 | """ 68 | config = get_configuration() 69 | if not config.get('redis'): return None 70 | 71 | return redis.Redis(**config['redis']) 72 | 73 | def get_requests_cache_backend(namespace): 74 | """ 75 | Use RedisCache is available, else fallback to SQLiteCache 76 | """ 77 | config = get_configuration() 78 | if not config.get('redis'): return SQLiteCache() 79 | 80 | return RedisCache(namespace, **config['redis']) 81 | 82 | def disable_outbound_federation(): 83 | config = get_configuration() 84 | ret = config.get('disable_outbound_federation', False) 85 | return ret if isinstance(ret, bool) else False 86 | -------------------------------------------------------------------------------- /federation/utils/matrix.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import uuid 4 | from typing import Dict, Optional 5 | 6 | import requests 7 | 8 | from federation.utils.django import get_function_from_config 9 | 10 | 11 | def appservice_auth_header() -> Dict: 12 | config = get_matrix_configuration() 13 | return { 14 | "Authorization": f"Bearer {config['appservice']['token']}", 15 | } 16 | 17 | 18 | def generate_dendrite_mac(shared_secret: str, username: str, password: str, admin: bool) -> str: 19 | """ 20 | Generate a MAC for using in registering users with Dendrite. 21 | """ 22 | # From: https://github.com/matrix-org/dendrite/blob/master/clientapi/routing/register.go 23 | mac = hmac.new( 24 | key=shared_secret.encode('utf8'), 25 | digestmod=hashlib.sha1, 26 | ) 27 | 28 | mac.update(username.encode('utf8')) 29 | mac.update(b"\x00") 30 | mac.update(password.encode('utf8')) 31 | mac.update(b"\x00") 32 | mac.update(b"admin" if admin else b"notadmin") 33 | return mac.hexdigest() 34 | 35 | 36 | def get_matrix_configuration() -> Optional[Dict]: 37 | """ 38 | Return Matrix configuration. 39 | 40 | Requires Django support currently. 41 | """ 42 | try: 43 | matrix_config_func = get_function_from_config("matrix_config_function") 44 | except AttributeError: 45 | raise AttributeError("Not configured for Matrix support") 46 | return matrix_config_func() 47 | 48 | 49 | def register_dendrite_user(username: str) -> Dict: 50 | """ 51 | Shared secret registration for Dendrite. 52 | 53 | Note uses the legacy route, see 54 | https://github.com/matrix-org/dendrite/issues/1669 55 | 56 | Currently compatible with Django apps only. 57 | 58 | Returns: 59 | { 60 | 'user_id': '@username:domain.tld', 61 | 'access_token': 'randomaccesstoken', 62 | 'home_server': 'domain.tld', 63 | 'device_id': 'randomdevice' 64 | } 65 | """ 66 | matrix_config = get_matrix_configuration 67 | 68 | password = str(uuid.uuid4()) 69 | mac = generate_dendrite_mac( 70 | matrix_config["registration_shared_secret"], 71 | username, 72 | password, 73 | False, 74 | ) 75 | 76 | # Register using shared secret 77 | response = requests.post( 78 | f"{matrix_config['homeserver_base_url']}/_matrix/client/api/v1/register?kind=user", 79 | json={ 80 | "type": "org.matrix.login.shared_secret", 81 | "mac": mac, 82 | "password": password, 83 | "user": username, 84 | "admin": False, 85 | }, 86 | headers={ 87 | "Content-Type": "application/json", 88 | }, 89 | ) 90 | response.raise_for_status() 91 | return response.json() 92 | -------------------------------------------------------------------------------- /federation/utils/protocols.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | from federation.utils.text import validate_handle 5 | 6 | 7 | def identify_recipient_protocol(id: str) -> Optional[str]: 8 | if re.match(r'^https?://', id, flags=re.IGNORECASE) is not None: 9 | return "activitypub" 10 | if validate_handle(id): 11 | return "diaspora" 12 | -------------------------------------------------------------------------------- /federation/utils/text.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Set, List 3 | from urllib.parse import urlparse 4 | 5 | from bs4 import BeautifulSoup 6 | from bs4.element import NavigableString 7 | from commonmark import commonmark 8 | 9 | ILLEGAL_TAG_CHARS = "!#$%^&*+.,@£/()=?`'\\{[]}~;:\"’”—\xa0" 10 | TAG_PATTERN = re.compile(r'(#[\w\-]+)([)\]_!?*%/.,;\s]+\s*|\Z)', re.UNICODE) 11 | # This will match non-matching braces. I don't think it's an issue. 12 | MENTION_PATTERN = re.compile(r'(@\{?(?:[^{}@;]*; *)?[\w\-.]+@[\w\-.]+\.[\w]+}?)', re.UNICODE) 13 | # based on https://stackoverflow.com/a/6041965 14 | URL_PATTERN = re.compile(r'((?:(?:https?|ftp)://|^|(?<=[("<\s]))+(?:[\w\-]+(?:(?:\.[\w\-]+)+))' 15 | r'[\w.,;:@?!$()*^=%&/~+\-#]*(?"]))', 16 | re.UNICODE) 17 | 18 | def decode_if_bytes(text): 19 | try: 20 | return text.decode("utf-8") 21 | except AttributeError: 22 | return text 23 | 24 | 25 | def encode_if_text(text): 26 | try: 27 | return bytes(text, encoding="utf-8") 28 | except TypeError: 29 | return text 30 | 31 | 32 | def find_tags(text: str) -> Set[str]: 33 | """Find tags in text. 34 | 35 | Ignore tags inside code blocks. 36 | 37 | Returns a set of tags. 38 | 39 | """ 40 | tags = find_elements(BeautifulSoup(commonmark(text, ignore_html_blocks=True), 'html.parser'), 41 | TAG_PATTERN) 42 | return set([tag.text.lstrip('#').lower() for tag in tags]) 43 | 44 | 45 | def find_elements(soup: BeautifulSoup, pattern: re.Pattern) -> List[NavigableString]: 46 | """ 47 | Split a BeautifulSoup tree strings according to a pattern, replacing each element 48 | with a NavigableString. The returned list can be used to linkify the found 49 | elements. 50 | 51 | :param soup: BeautifulSoup instance of the content being searched 52 | :param pattern: Compiled regular expression defined using a single group 53 | :return: A NavigableString list attached to the original soup 54 | """ 55 | final = [] 56 | for candidate in soup.find_all(string=True): 57 | if candidate.parent.name == 'code': continue 58 | ns = [NavigableString(r) for r in pattern.split(candidate.text) if r] 59 | found = [s for s in ns if pattern.match(s.text)] 60 | if found: 61 | candidate.replace_with(*ns) 62 | final.extend(found) 63 | return final 64 | 65 | 66 | def get_path_from_url(url: str) -> str: 67 | """ 68 | Return only the path part of an URL. 69 | """ 70 | parsed = urlparse(url) 71 | return parsed.path 72 | 73 | 74 | 75 | def test_tag(tag: str) -> bool: 76 | """Test a word whether it could be accepted as a tag.""" 77 | if not tag: 78 | return False 79 | for char in ILLEGAL_TAG_CHARS: 80 | if char in tag: 81 | return False 82 | return True 83 | 84 | 85 | def validate_handle(handle): 86 | """ 87 | Very basic handle validation as per 88 | https://diaspora.github.io/diaspora_federation/federation/types.html#diaspora-id 89 | """ 90 | return re.match(r"[a-z0-9\-_.]+@[^@/]+\.[^@/]+", handle, flags=re.IGNORECASE) is not None 91 | 92 | 93 | def with_slash(url): 94 | if url.endswith('/'): 95 | return url 96 | return f"{url}/" 97 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = federation 3 | DJANGO_SETTINGS_MODULE = federation.tests.django.settings 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | from setuptools import setup, find_packages 5 | 6 | from federation import __version__ 7 | 8 | 9 | description = 'Python library to abstract social web federation protocols like ActivityPub, Matrix and Diaspora.' 10 | 11 | 12 | def get_long_description(): 13 | return open(os.path.join(os.path.dirname(__file__), "docs", "introduction.rst")).read() 14 | setup( 15 | name='federation', 16 | version=__version__, 17 | description=description, 18 | long_description=get_long_description(), 19 | author='Jason Robinson', 20 | author_email='mail@jasonrobinson.me', 21 | maintainer='Jason Robinson', 22 | maintainer_email='mail@jasonrobinson.me', 23 | url='https://codeberg.org/socialhome/federation', 24 | download_url='https://pypi.org/project/federation/', 25 | packages=find_packages(), 26 | license="BSD 3-clause", 27 | install_requires=[ 28 | "attrs", 29 | "beautifulsoup4>=4.11.2", 30 | "bleach>3.0", 31 | "calamus", 32 | "commonmark_socialhome>=0.9.1.post2", 33 | "cryptography", 34 | "cssselect>=0.9.2", 35 | "dirty-validators>=0.3.0", 36 | "funcy", 37 | "lxml>=3.4.0", 38 | "iteration_utilities", 39 | "jsonschema>=2.0.0", 40 | "markdownify", 41 | "pycryptodome>=3.4.10", 42 | "python-dateutil>=2.4.0", 43 | "python-httpsig-socialhome", 44 | "python-magic", 45 | "python-slugify>=5.0.0", 46 | "python-xrd>=0.1", 47 | "pytz", 48 | "PyYAML", 49 | "redis", 50 | "requests>=2.8.0", 51 | "requests-cache", 52 | ], 53 | include_package_data=True, 54 | classifiers=[ 55 | 'Development Status :: 4 - Beta', 56 | 'Environment :: Web Environment', 57 | 'Intended Audience :: Developers', 58 | 'License :: OSI Approved :: BSD License', 59 | 'Programming Language :: Python', 60 | 'Programming Language :: Python :: 3 :: Only', 61 | 'Programming Language :: Python :: 3.8', 62 | 'Programming Language :: Python :: 3.9', 63 | 'Programming Language :: Python :: 3.10', 64 | 'Programming Language :: Python :: Implementation :: CPython', 65 | 'Topic :: Communications', 66 | 'Topic :: Internet', 67 | 'Topic :: Software Development :: Libraries :: Python Modules', 68 | ], 69 | keywords='federation diaspora activitypub matrix protocols federate fediverse social', 70 | ) 71 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # tox (https://tox.readthedocs.io/) is a tool for running tests 2 | # in multiple virtualenvs. This configuration file will run the 3 | # test suite on all supported python versions. To use it, "pip install tox" 4 | # and then run "tox" from this directory. 5 | 6 | [tox] 7 | envlist = py310 8 | 9 | [testenv] 10 | usedevelop = True 11 | 12 | deps = -rdev-requirements.txt 13 | 14 | commands = 15 | pip freeze 16 | pytest --cov=./ 17 | #codecov 18 | --------------------------------------------------------------------------------