From e343369f5bd054ceb1681798974514f2e04341a3 Mon Sep 17 00:00:00 2001 From: Jason Robinson Date: Sat, 21 Oct 2017 01:31:56 +0300 Subject: [PATCH 1/2] New high level fetcher function retrieve_remote_content The given ID will be fetched using the correct entity class specific remote endpoint, validated to be from the correct author against their public key and then an instance of the entity class will be constructed and returned. Also related changes and refactoring: * New Diaspora protocol helper `federation.utils.diaspora.retrieve_and_parse_content`. See notes regarding the high level fetcher above. * New Diaspora protocol helper `federation.utils.fetch_public_key`. Given a `handle` as a parameter, will fetch the remote profile and return the `public_key` from it. * Refactoring for Diaspora `MagicEnvelope` class. * Diaspora procotol receive flow now uses the `MagicEnvelope` class to verify payloads. * Diaspora protocol receive flow now fetches the sender public key over the network if a `sender_key_fetcher` function is not passed in. Previously an error would be raised. Closes #103 --- CHANGELOG.md | 25 +++++ docs/usage.rst | 5 + federation/entities/diaspora/mappers.py | 12 +++ federation/fetchers.py | 19 +++- .../protocols/diaspora/magic_envelope.py | 99 +++++++++++++++++-- federation/protocols/diaspora/protocol.py | 24 ++--- federation/tests/conftest.py | 11 +++ federation/tests/fixtures/keys.py | 39 +++++++- .../protocols/diaspora/test_magic_envelope.py | 82 ++++++++++++--- .../tests/protocols/diaspora/test_protocol.py | 38 +++++-- .../protocols/diaspora/test_signatures.py | 41 +------- federation/tests/test_fetchers.py | 26 +++-- federation/tests/utils/test_diaspora.py | 81 ++++++++++++--- federation/utils/diaspora.py | 58 ++++++++++- 14 files changed, 451 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19755b4..9fa8436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ * Added base entity `Share` which maps to a `DiasporaReshare` for the Diaspora protocol. ([related issue](https://github.com/jaywink/federation/issues/94)) The `Share` entity supports all the properties that a Diaspora reshare does. Additionally two other properties are supported: `raw_content` and `entity_type`. The former can be used for a "quoted share" case where the sharer adds their own note to the share. The latter can be used to reference the type of object that was shared, to help the receiver, if it is not sharing a `Post` entity. The value must be a base entity class name. + +* New high level fetcher function `federation.fetchers.retrieve_remote_content`. ([related issue](https://github.com/jaywink/federation/issues/103)) + + This function takes the following parameters: + + * `entity_class` - Base entity class to fetch (for example `Post`). + * `id` - Object ID. Currently since only Diaspora is supported and the ID is expected to be in format `@`. + * `sender_key_fetcher` - Optional function that takes a profile `handle` and returns a public key in `str` format. If this is not given, the public key will be fetched from the remote profile over the network. + + The given ID will be fetched using the correct entity class specific remote endpoint, validated to be from the correct author against their public key and then an instance of the entity class will be constructed and returned. + +* New Diaspora protocol helper `federation.utils.diaspora.retrieve_and_parse_content`. See notes regarding the high level fetcher above. + +* New Diaspora protocol helper `federation.utils.fetch_public_key`. Given a `handle` as a parameter, will fetch the remote profile and return the `public_key` from it. + +### Changed +* Refactoring for Diaspora `MagicEnvelope` class. + + The class init now also allows passing in parameters to construct and verify MagicEnvelope instances. The order of init parameters has not been changed, but they are now all optional. When creating a class instance, one should always pass in the necessary parameters depnding on whether the class instance will be used for building a payload or verifying an incoming payload. See class docstring for details. + +* Diaspora procotol receive flow now uses the `MagicEnvelope` class to verify payloads. + +* Diaspora protocol receive flow now fetches the sender public key over the network if a `sender_key_fetcher` function is not passed in. Previously an error would be raised. + + Note that fetching over the network for each payload is wasteful. Implementers should instead cache public keys when possible and pass in a function to retrieve them, as before. ### Fixed * Converting base entity `Profile` to `DiasporaProfile` for outbound sending missed two attributes, `image_urls` and `tag_list`. Those are now included so that the values transfer into the built payload. diff --git a/docs/usage.rst b/docs/usage.rst index 5a84ebd..ce15bdd 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -66,6 +66,7 @@ Fetchers High level utility functions to fetch remote objects. These should be favoured instead of protocol specific utility functions. +.. autofunction:: federation.fetchers.retrieve_remote_content .. autofunction:: federation.fetchers.retrieve_remote_profile @@ -105,7 +106,11 @@ Various utils are provided for internal and external usage. Diaspora ........ +.. autofunction:: federation.utils.diaspora.fetch_public_key +.. autofunction:: federation.utils.diaspora.get_fetch_content_endpoint +.. autofunction:: federation.utils.diaspora.get_public_endpoint .. autofunction:: federation.utils.diaspora.parse_profile_from_hcard +.. autofunction:: federation.utils.diaspora.retrieve_and_parse_content .. autofunction:: federation.utils.diaspora.retrieve_and_parse_profile .. autofunction:: federation.utils.diaspora.retrieve_diaspora_hcard .. autofunction:: federation.utils.diaspora.retrieve_diaspora_webfinger diff --git a/federation/entities/diaspora/mappers.py b/federation/entities/diaspora/mappers.py index 5acd205..79f8ba3 100644 --- a/federation/entities/diaspora/mappers.py +++ b/federation/entities/diaspora/mappers.py @@ -12,6 +12,18 @@ from federation.utils.diaspora import retrieve_and_parse_profile logger = logging.getLogger("federation") +BASE_MAPPINGS = { + Comment: "comment", + Follow: "contact", + Image: "photo", + Post: "status_message", + Profile: "profile", + Reaction: "like", + Relationship: "request", + Retraction: "retraction", + Share: "reshare", +} + MAPPINGS = { "status_message": DiasporaPost, "photo": Image, diff --git a/federation/fetchers.py b/federation/fetchers.py index 46b9a5d..12d830b 100644 --- a/federation/fetchers.py +++ b/federation/fetchers.py @@ -1,7 +1,22 @@ -# -*- coding: utf-8 -*- import importlib +def retrieve_remote_content(entity_class, id, sender_key_fetcher=None): + """Retrieve remote content and return an Entity object. + + Currently, due to no other protocols supported, always use the Diaspora protocol. + + :param entity_class: Federation entity class (from ``federation.entity.base``). + :param id: ID of the remote entity, in format``guid@domain.tld``. + :param sender_key_fetcher: Function to use to fetch sender public key. If not given, network will be used + to fetch the profile and the key. Function must take handle as only parameter and return a public key. + :returns: Entity class instance or ``None`` + """ + protocol_name = "diaspora" + utils = importlib.import_module("federation.utils.%s" % protocol_name) + return utils.retrieve_and_parse_content(entity_class, id, sender_key_fetcher=sender_key_fetcher) + + def retrieve_remote_profile(handle): """High level retrieve profile method. @@ -10,7 +25,7 @@ def retrieve_remote_profile(handle): Currently, due to no other protocols supported, always use the Diaspora protocol. - :arg handle: The profile handle in format username@domain.tld + :param handle: The profile handle in format username@domain.tld :returns: ``federation.entities.base.Profile`` or ``None`` """ protocol_name = "diaspora" diff --git a/federation/protocols/diaspora/magic_envelope.py b/federation/protocols/diaspora/magic_envelope.py index 46a9a08..7279fe5 100644 --- a/federation/protocols/diaspora/magic_envelope.py +++ b/federation/protocols/diaspora/magic_envelope.py @@ -1,9 +1,13 @@ from base64 import urlsafe_b64encode, b64encode, urlsafe_b64decode from Crypto.Hash import SHA256 -from Crypto.Signature import PKCS1_v1_5 as PKCSSign +from Crypto.PublicKey import RSA +from Crypto.Signature import PKCS1_v1_5 from lxml import etree +from federation.exceptions import SignatureVerificationError +from federation.utils.diaspora import fetch_public_key +from federation.utils.text import decode_if_bytes NAMESPACE = "http://salmon-protocol.org/ns/magic-env" @@ -11,25 +15,70 @@ NAMESPACE = "http://salmon-protocol.org/ns/magic-env" class MagicEnvelope: """Diaspora protocol magic envelope. - See: http://diaspora.github.io/diaspora_federation/federation/magicsig.html + Can be used to construct and deconstruct MagicEnvelope documents. + + When constructing, the following parameters should be given: + * message + * private_key + * author_handle + + When deconstructing, the following should be given: + * payload + * public_key (optional, will be fetched if not given, using either 'sender_key_fetcher' or remote server) + + Upstream specification: http://diaspora.github.io/diaspora_federation/federation/magicsig.html """ nsmap = { "me": NAMESPACE, } - def __init__(self, message, private_key, author_handle, wrap_payload=False): + def __init__(self, message=None, private_key=None, author_handle=None, wrap_payload=False, payload=None, + public_key=None, sender_key_fetcher=None, verify=False, doc=None): """ - Args: - wrap_payload (bool) - Whether to wrap the message in . - This is part of the legacy Diaspora protocol which will be removed in the future. (default False) + All parameters are optional. Some are required for signing, some for opening. + + :param message: Message string. Required to create a MagicEnvelope document. + :param private_key: Private key RSA object. + :param author_handle: Author signing the Magic Envelope, owns the private key. + :param wrap_payload: - Boolean, whether to wrap the message in . + This is part of the legacy Diaspora protocol which will be removed in the future. (default False) + :param payload: Magic Envelope payload as str or bytes. + :param public_key: Author public key in str format. + :param sender_key_fetcher: Function to use to fetch sender public key, if public key not given. Will fall back + to network fetch of the profile and the key. Function must take handle as only parameter and return + a public key string. + :param verify: Verify after creating object, defaults to False. + :param doc: MagicEnvelope document. """ - self.message = message + self._message = message self.private_key = private_key self.author_handle = author_handle self.wrap_payload = wrap_payload - self.doc = None - self.payload = None + self.payload = payload + self.public_key = public_key + self.sender_key_fetcher = sender_key_fetcher + if payload: + self.extract_payload() + elif doc is not None: + self.doc = doc + else: + self.doc = None + if verify: + self.verify() + + def extract_payload(self): + payload = decode_if_bytes(self.payload) + payload = payload.lstrip().encode("utf-8") + self.doc = etree.fromstring(payload) + self.author_handle = self.get_sender(self.doc) + self.message = self.message_from_doc() + + def fetch_public_key(self): + if self.sender_key_fetcher: + self.public_key = self.sender_key_fetcher(self.author_handle) + return + self.public_key = fetch_public_key(self.author_handle) @staticmethod def get_sender(doc): @@ -41,6 +90,19 @@ class MagicEnvelope: key_id = doc.find(".//{%s}sig" % NAMESPACE).get("key_id") return urlsafe_b64decode(key_id).decode("utf-8") + @property + def message(self): + return self._message + + @message.setter + def message(self, value): + self._message = value + + def message_from_doc(self): + message = self.doc.find( + ".//{http://salmon-protocol.org/ns/magic-env}data").text + return urlsafe_b64decode(message.encode("ascii")) + def create_payload(self): """Create the payload doc. @@ -65,7 +127,7 @@ class MagicEnvelope: b64encode(b"base64url").decode("ascii") + "." + \ b64encode(b"RSA-SHA256").decode("ascii") sig_hash = SHA256.new(sig_contents.encode("ascii")) - cipher = PKCSSign.new(self.private_key) + cipher = PKCS1_v1_5.new(self.private_key) sig = urlsafe_b64encode(cipher.sign(sig_hash)) key_id = urlsafe_b64encode(bytes(self.author_handle, encoding="utf-8")) return sig, key_id @@ -84,3 +146,20 @@ class MagicEnvelope: if self.doc is None: self.build() return etree.tostring(self.doc, encoding="unicode") + + def verify(self): + """Verify Magic Envelope document against public key.""" + if not self.public_key: + self.fetch_public_key() + data = self.doc.find(".//{http://salmon-protocol.org/ns/magic-env}data").text + sig = self.doc.find(".//{http://salmon-protocol.org/ns/magic-env}sig").text + sig_contents = '.'.join([ + data, + b64encode(b"application/xml").decode("ascii"), + b64encode(b"base64url").decode("ascii"), + b64encode(b"RSA-SHA256").decode("ascii") + ]) + sig_hash = SHA256.new(sig_contents.encode("ascii")) + cipher = PKCS1_v1_5.new(RSA.importKey(self.public_key)) + if not cipher.verify(sig_hash, urlsafe_b64decode(sig)): + raise SignatureVerificationError("Signature cannot be verified using the given public key") diff --git a/federation/protocols/diaspora/protocol.py b/federation/protocols/diaspora/protocol.py index 4e094bc..100cbdc 100644 --- a/federation/protocols/diaspora/protocol.py +++ b/federation/protocols/diaspora/protocol.py @@ -5,15 +5,15 @@ from urllib.parse import unquote_plus from Crypto.Cipher import AES, PKCS1_v1_5 from Crypto.Hash import SHA256 -from Crypto.PublicKey import RSA from Crypto.Random import get_random_bytes from Crypto.Signature import PKCS1_v1_5 as PKCSSign from lxml import etree -from federation.exceptions import EncryptedMessageError, NoSenderKeyFoundError, SignatureVerificationError +from federation.exceptions import EncryptedMessageError, NoSenderKeyFoundError from federation.protocols.base import BaseProtocol from federation.protocols.diaspora.encrypted import EncryptedPayload from federation.protocols.diaspora.magic_envelope import MagicEnvelope +from federation.utils.diaspora import fetch_public_key from federation.utils.text import decode_if_bytes, encode_if_text logger = logging.getLogger("federation") @@ -174,23 +174,13 @@ class Protocol(BaseProtocol): Verify the signed XML elements to have confidence that the claimed author did actually generate this message. """ - sender_key = self.get_contact_key(self.sender_handle) + if self.get_contact_key: + sender_key = self.get_contact_key(self.sender_handle) + else: + sender_key = fetch_public_key(self.sender_handle) if not sender_key: raise NoSenderKeyFoundError("Could not find a sender contact to retrieve key") - body = self.doc.find( - ".//{http://salmon-protocol.org/ns/magic-env}data").text - sig = self.doc.find( - ".//{http://salmon-protocol.org/ns/magic-env}sig").text - sig_contents = '.'.join([ - body, - b64encode(b"application/xml").decode("ascii"), - b64encode(b"base64url").decode("ascii"), - b64encode(b"RSA-SHA256").decode("ascii") - ]) - sig_hash = SHA256.new(sig_contents.encode("ascii")) - cipher = PKCSSign.new(RSA.importKey(sender_key)) - if not cipher.verify(sig_hash, urlsafe_b64decode(sig)): - raise SignatureVerificationError("Signature cannot be verified using the given contact key") + MagicEnvelope(doc=self.doc, public_key=sender_key, verify=True) def parse_header(self, b64data, key): """ diff --git a/federation/tests/conftest.py b/federation/tests/conftest.py index e2bd947..3523317 100644 --- a/federation/tests/conftest.py +++ b/federation/tests/conftest.py @@ -4,6 +4,7 @@ import pytest from federation.entities.diaspora.entities import DiasporaPost from federation.tests.fixtures.keys import get_dummy_private_key +from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD @pytest.fixture(autouse=True) @@ -22,6 +23,11 @@ def disable_network_calls(monkeypatch): monkeypatch.setattr("requests.get", Mock(return_value=MockResponse)) +@pytest.fixture +def diaspora_public_payload(): + return DIASPORA_PUBLIC_PAYLOAD + + @pytest.fixture def diasporapost(): return DiasporaPost() @@ -30,3 +36,8 @@ def diasporapost(): @pytest.fixture def private_key(): return get_dummy_private_key() + + +@pytest.fixture +def public_key(private_key): + return private_key.publickey().exportKey() diff --git a/federation/tests/fixtures/keys.py b/federation/tests/fixtures/keys.py index ad699b6..19291f8 100644 --- a/federation/tests/fixtures/keys.py +++ b/federation/tests/fixtures/keys.py @@ -1,6 +1,5 @@ from Crypto.PublicKey import RSA - PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\n" \ "MIIEogIBAAKCAQEAiY2JBgMV90ULt0btku198l6wGuzn3xCcHs+eBZHL2C+XWRA3\n" \ "BVDThSBj19dKXehfDphQ5u/Omfm76ImajEPHGBiYtZT7AgcO15zvm+JCpbREbdOV\n" \ @@ -29,6 +28,44 @@ PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\n" \ "w6Y5FnjFw022w+M3exyH6ZtxcmG6buDbp2F/SPD/FnYy5IFCDig=\n" \ "-----END RSA PRIVATE KEY-----" +# Not related to above private key +PUBKEY = "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuCfU1G5X+3O6vPdSz6QY\nSFbgdbv3KPv" \ + "xHi8tRmlyOLdLt5i1eqsy2WCW1iYNijiCL7OfbrvymBQxe3GA9S64\nVuavwzQ8nO7nzpNMqxY5tBXsBM1lECCHDOvm5dzINXWT9Sg7P1" \ + "8iIxE/2wQEgMUL\nAeVbJtAriXM4zydL7c91agFMJu1aHp0lxzoH8I13xzUetGMutR1tbcfWvoQvPAoU\n89uAz5j/DFMhWrkVEKGeWt1" \ + "YtHMmJqpYqR6961GDlwRuUsOBsLgLLVohzlBsTBSn\n3580o2E6G3DEaX0Az9WB9ylhNeV/L/PP3c5htpEyoPZSy1pgtut6TRYQwC8wns" \ + "qO\nbVIbFBkrKoaRDyVCnpMuKdDNLZqOOfhzas+SWRAby6D8VsXpPi/DpeS9XkX0o/uH\nJ9N49GuYMSUGC8gKtaddD13pUqS/9rpSvLD" \ + "rrDQe5Lhuyusgd28wgEAPCTmM3pEt\nQnlxEeEmFMIn3OBLbEDw5TFE7iED0z7a4dAkqqz8KCGEt12e1Kz7ujuOVMxJxzk6\nNtwt40Sq" \ + "EOPcdsGHAA+hqzJnXUihXfmtmFkropaCxM2f+Ha0bOQdDDui5crcV3sX\njShmcqN6YqFzmoPK0XM9P1qC+lfL2Mz6bHC5p9M8/FtcM46" \ + "hCj1TF/tl8zaZxtHP\nOrMuFJy4j4yAsyVy3ddO69ECAwEAAQ==\n-----END PUBLIC KEY-----\n" + +SIGNATURE = "A/vVRxM3V1ceEH1JrnPOaIZGM3gMjw/fnT9TgUh3poI4q9eH95AIoig+3eTA8XFuGvuo0tivxci4e0NJ1VLVkl/aqp8rvBNrRI1RQk" \ + "n2WVF6zk15Gq6KSia/wyzyiJHGxNGM8oFY4qPfNp6K+8ydUti22J11tVBEvQn+7FPAoloF2Xz1waK48ZZCFs8Rxzj+4jlz1PmuXCnT" \ + "j7v7GYS1Rb6sdFz4nBSuVk5X8tGOSXIRYxPgmtsDRMRrvDeEK+v3OY6VnT8dLTckS0qCwTRUULub1CGwkz/2mReZk/M1W4EbUnugF5" \ + "ptslmFqYDYJZM8PA/g89EKVpkx2gaFbsC4KXocWnxHNiue18rrFQ5hMnDuDRiRybLnQkxXbE/HDuLdnognt2S5wRshPoZmhe95v3qq" \ + "/5nH/GX1D7VmxEEIG9fX+XX+Vh9kzO9bLbwoJZwm50zXxCvrLlye/2JU5Vd2Hbm4aMuAyRAZiLS/EQcBlsts4DaFu4txe60HbXSh6n" \ + "qNofGkusuzZnCd0VObOpXizrI8xNQzZpjJEB5QqE2gbCC2YZNdOS0eBGXw42dAXa/QV3jZXGES7DdQlqPqqT3YjcMFLiRrWQR8cl4h" \ + "JIBRpV5piGyLmMMKYrWu7hQSrdRAEL3K6mNZZU6/yoG879LjtQbVwaFGPeT29B4zBE97FIo=" + +SIGNATURE2 = "Xla/AlirMihx72hehGMgpKILRUA2ZkEhFgVc65sl80iN+F62yQdSikGyUQVL+LaGNUgmzgK0zEahamfaMFep/9HE2FWuXlTCM+ZXx" \ + "OhGWUnjkGW9vi41/Turm7ALzaJoFm1f3Iv4nh1sRD1jySzlZvYwrq4LwmgZ8r0M+Q6xUSIIJfgS8Zjmp43strKo28vKT+DmUKu9Fg" \ + "jZWjW3S8WPPJFO0UqA0b1UQspmNLZOVxsNpa0OCM1pofJvT09n6xG+byV30Bed27Kw+D3fzfYq5xvohyeCyliTq8LHnOykecki3Y2" \ + "Pvl1qsxxBehlwc/WH8yIUiwC2Du6zY61tN3LGgMAoIFl40Roo1z/I7YfOy4ZCukOGqqyiLdjoXxIVQqqsPtKsrVXS+A9OQ+sVESgw" \ + "f8jeEIw/KXLVB/aEyrZJXQR1pBfqkOTCSnAfZVBSjJyxhanS/8iGmnRV5zz3auYMLR9aA8QHjV/VZOj0Bxhuba9VIzJlY9XoUt5Vs" \ + "h3uILJM3uVJzSjlZV+Jw3O+NdQFnZyh7m1+eJUMQJ8i0Sr3sMLsdb9me/I0HueXCa5eBHAoTtAyQgS4uN4NMhvpqrB/lQCx7pqnkt" \ + "xiCO/bUEZONQjWrvJT+EfD+I0UMFtPFiGDzJ0yi0Ah7LxSTGEGPFZHH5RgsJA8lJwGMCUtc9Cpy8A=" + +SIGNATURE3 = "hVdLwsWXe6yVy88m9H1903+Bj/DjSGsYL+ZIpEz+G6u/aVx6QfsvnWHzasjqN8SU+brHfL0c8KrapWcACO+jyCuXlHMZb9zKmJkHR" \ + "FSOiprCJ3tqNpv/4MIa9CXu0YDqnLHBSyxS01luKw3EqgpWPQdYcqDpOkjjTOq45dQC0PGHA/DXjP7LBptV9AwW200LIcL5Li8tDU" \ + "a8VSQybspDDfDpXU3+Xl5tJIBVS4ercPczp5B39Cwne4q2gyj/Y5RdIoX5RMqmFhfucw1he38T1oRC9AHTJqj4CBcDt7gc6jPHuzk" \ + "N7u1eUf0IK3+KTDKsCkkoHcGaoxT+NeWcS8Ki1A==" + +XML = "0dd40d800db1013514416c626dd5570369ab2b83-aa69-4456-ad0a-dd669" \ + "7f54714Woop Woopjaywink@iliketoast.net" + +XML2 = "d728fe501584013514526c626dd55703d641bd35-8142-414e-a12d-f956cc2c1bb9" \ + "What about the mystical problem with 👍 (pt2 with more logging)" \ + "jaywink@iliketoast.net" + def get_dummy_private_key(): return RSA.importKey(PRIVATE_KEY) diff --git a/federation/tests/protocols/diaspora/test_magic_envelope.py b/federation/tests/protocols/diaspora/test_magic_envelope.py index 952ec2e..8d44ba9 100644 --- a/federation/tests/protocols/diaspora/test_magic_envelope.py +++ b/federation/tests/protocols/diaspora/test_magic_envelope.py @@ -1,21 +1,16 @@ -from lxml import etree +from unittest.mock import patch, Mock -from Crypto import Random -from Crypto.PublicKey import RSA +import pytest +from lxml import etree from lxml.etree import _Element +from federation.exceptions import SignatureVerificationError from federation.protocols.diaspora.magic_envelope import MagicEnvelope -from federation.tests.fixtures.keys import get_dummy_private_key +from federation.tests.fixtures.keys import get_dummy_private_key, PUBKEY from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD -class TestMagicEnvelope(): - @staticmethod - def generate_rsa_private_key(): - """Generate a new RSA private key.""" - rand = Random.new().read - return RSA.generate(2048, rand) - +class TestMagicEnvelope: def test_build(self): env = MagicEnvelope( message="bar", @@ -40,11 +35,74 @@ class TestMagicEnvelope(): env = MagicEnvelope( message="bar", private_key="key", - author_handle="foobar@example.com" + author_handle="foobar@example.com", ) payload = env.create_payload() assert payload == "PHN0YXR1c19tZXNzYWdlPjxmb28-YmFyPC9mb28-PC9zdGF0dXNfbWVzc2FnZT4=" + def test_extract_payload(self, diaspora_public_payload): + env = MagicEnvelope() + env.payload = diaspora_public_payload + assert not env.doc + assert not env.author_handle + assert not env.message + env.extract_payload() + assert isinstance(env.doc, _Element) + assert env.author_handle == "foobar@example.com" + assert env.message == b"bar" + + @patch("federation.protocols.diaspora.magic_envelope.fetch_public_key", autospec=True) + def test_fetch_public_key__calls_sender_key_fetcher(self, mock_fetch): + mock_fetcher = Mock(return_value="public key") + env = MagicEnvelope(author_handle="spam@eggs", sender_key_fetcher=mock_fetcher) + env.fetch_public_key() + mock_fetcher.assert_called_once_with("spam@eggs") + assert not mock_fetch.called + + @patch("federation.protocols.diaspora.magic_envelope.fetch_public_key", autospec=True) + def test_fetch_public_key__calls_fetch_public_key(self, mock_fetch): + env = MagicEnvelope(author_handle="spam@eggs") + env.fetch_public_key() + mock_fetch.assert_called_once_with("spam@eggs") + + def test_message_from_doc(self, diaspora_public_payload): + env = MagicEnvelope(payload=diaspora_public_payload) + assert env.message_from_doc() == env.message + + def test_payload_extracted_on_init(self, diaspora_public_payload): + env = MagicEnvelope(payload=diaspora_public_payload) + assert isinstance(env.doc, _Element) + assert env.author_handle == "foobar@example.com" + assert env.message == b"bar" + + def test_verify(self, private_key, public_key): + me = MagicEnvelope( + message="bar", + private_key=private_key, + author_handle="foobar@example.com" + ) + me.build() + output = me.render() + + MagicEnvelope(payload=output, public_key=public_key, verify=True) + + with pytest.raises(SignatureVerificationError): + MagicEnvelope(payload=output, public_key=PUBKEY, verify=True) + + def test_verify__calls_fetch_public_key(self, diaspora_public_payload): + me = MagicEnvelope(payload=diaspora_public_payload) + with pytest.raises(TypeError): + with patch.object(me, "fetch_public_key") as mock_fetch: + me.verify() + mock_fetch.assert_called_once_with() + + @patch("federation.protocols.diaspora.magic_envelope.MagicEnvelope.verify") + def test_verify_on_init(self, mock_verify, diaspora_public_payload): + MagicEnvelope(payload=diaspora_public_payload) + assert not mock_verify.called + MagicEnvelope(payload=diaspora_public_payload, verify=True) + assert mock_verify.called + def test_build_signature(self): env = MagicEnvelope( message="bar", diff --git a/federation/tests/protocols/diaspora/test_protocol.py b/federation/tests/protocols/diaspora/test_protocol.py index 66df93b..199d8b7 100644 --- a/federation/tests/protocols/diaspora/test_protocol.py +++ b/federation/tests/protocols/diaspora/test_protocol.py @@ -5,16 +5,16 @@ from xml.etree.ElementTree import ElementTree from lxml import etree import pytest -from federation.exceptions import EncryptedMessageError, NoSenderKeyFoundError +from federation.exceptions import EncryptedMessageError, NoSenderKeyFoundError, SignatureVerificationError from federation.protocols.diaspora.protocol import Protocol, identify_payload +from federation.tests.fixtures.keys import PUBKEY from federation.tests.fixtures.payloads import ( - ENCRYPTED_LEGACY_DIASPORA_PAYLOAD, UNENCRYPTED_LEGACY_DIASPORA_PAYLOAD, - DIASPORA_PUBLIC_PAYLOAD, + ENCRYPTED_LEGACY_DIASPORA_PAYLOAD, UNENCRYPTED_LEGACY_DIASPORA_PAYLOAD, DIASPORA_PUBLIC_PAYLOAD, DIASPORA_ENCRYPTED_PAYLOAD, ) -class MockUser(): +class MockUser: private_key = "foobar" def __init__(self, nokey=False): @@ -30,7 +30,7 @@ def mock_not_found_get_contact_key(contact): return None -class DiasporaTestBase(): +class DiasporaTestBase: def init_protocol(self): return Protocol() @@ -99,11 +99,37 @@ class TestDiasporaProtocol(DiasporaTestBase): with pytest.raises(EncryptedMessageError): protocol.receive(ENCRYPTED_LEGACY_DIASPORA_PAYLOAD, user) - def test_receive_raises_if_sender_key_cannot_be_found(self): + @patch("federation.protocols.diaspora.protocol.fetch_public_key", autospec=True) + def test_receive_raises_if_sender_key_cannot_be_found(self, mock_fetch): protocol = self.init_protocol() user = self.get_mock_user() with pytest.raises(NoSenderKeyFoundError): protocol.receive(UNENCRYPTED_LEGACY_DIASPORA_PAYLOAD, user, mock_not_found_get_contact_key) + assert not mock_fetch.called + + @patch("federation.protocols.diaspora.protocol.fetch_public_key", autospec=True, return_value=None) + def test_receive_calls_fetch_public_key_if_key_fetcher_not_given(self, mock_fetch): + protocol = self.init_protocol() + user = self.get_mock_user() + with pytest.raises(NoSenderKeyFoundError): + protocol.receive(UNENCRYPTED_LEGACY_DIASPORA_PAYLOAD, user) + mock_fetch.assert_called_once_with("bob@example.com") + + @patch("federation.protocols.diaspora.protocol.MagicEnvelope", autospec=True) + @patch("federation.protocols.diaspora.protocol.fetch_public_key", autospec=True, return_value="key") + def test_receive_creates_and_verifies_magic_envelope_instance(self, mock_fetch, mock_env): + protocol = self.init_protocol() + user = self.get_mock_user() + protocol.receive(UNENCRYPTED_LEGACY_DIASPORA_PAYLOAD, user) + mock_env.assert_called_once_with(doc=protocol.doc, public_key="key", verify=True) + + @patch("federation.protocols.diaspora.protocol.fetch_public_key", autospec=True) + def test_receive_raises_on_signature_verification_failure(self, mock_fetch): + mock_fetch.return_value = PUBKEY + protocol = self.init_protocol() + user = self.get_mock_user() + with pytest.raises(SignatureVerificationError): + protocol.receive(DIASPORA_PUBLIC_PAYLOAD, user) def test_get_message_content(self): protocol = self.init_protocol() diff --git a/federation/tests/protocols/diaspora/test_signatures.py b/federation/tests/protocols/diaspora/test_signatures.py index 062e3c8..a6b1b56 100644 --- a/federation/tests/protocols/diaspora/test_signatures.py +++ b/federation/tests/protocols/diaspora/test_signatures.py @@ -1,44 +1,7 @@ from lxml import etree -from federation.protocols.diaspora.signatures import verify_relayable_signature, create_relayable_signature -from federation.tests.fixtures.keys import get_dummy_private_key - -XML = "0dd40d800db1013514416c626dd5570369ab2b83-aa69-4456-ad0a-dd669" \ - "7f54714Woop Woopjaywink@iliketoast.net" - -XML2 = "d728fe501584013514526c626dd55703d641bd35-8142-414e-a12d-f956cc2c1bb9" \ - "What about the mystical problem with 👍 (pt2 with more logging)" \ - "jaywink@iliketoast.net" - -SIGNATURE = "A/vVRxM3V1ceEH1JrnPOaIZGM3gMjw/fnT9TgUh3poI4q9eH95AIoig+3eTA8XFuGvuo0tivxci4e0NJ1VLVkl/aqp8rvBNrRI1RQk" \ - "n2WVF6zk15Gq6KSia/wyzyiJHGxNGM8oFY4qPfNp6K+8ydUti22J11tVBEvQn+7FPAoloF2Xz1waK48ZZCFs8Rxzj+4jlz1PmuXCnT" \ - "j7v7GYS1Rb6sdFz4nBSuVk5X8tGOSXIRYxPgmtsDRMRrvDeEK+v3OY6VnT8dLTckS0qCwTRUULub1CGwkz/2mReZk/M1W4EbUnugF5" \ - "ptslmFqYDYJZM8PA/g89EKVpkx2gaFbsC4KXocWnxHNiue18rrFQ5hMnDuDRiRybLnQkxXbE/HDuLdnognt2S5wRshPoZmhe95v3qq" \ - "/5nH/GX1D7VmxEEIG9fX+XX+Vh9kzO9bLbwoJZwm50zXxCvrLlye/2JU5Vd2Hbm4aMuAyRAZiLS/EQcBlsts4DaFu4txe60HbXSh6n" \ - "qNofGkusuzZnCd0VObOpXizrI8xNQzZpjJEB5QqE2gbCC2YZNdOS0eBGXw42dAXa/QV3jZXGES7DdQlqPqqT3YjcMFLiRrWQR8cl4h" \ - "JIBRpV5piGyLmMMKYrWu7hQSrdRAEL3K6mNZZU6/yoG879LjtQbVwaFGPeT29B4zBE97FIo=" - -SIGNATURE2 = "Xla/AlirMihx72hehGMgpKILRUA2ZkEhFgVc65sl80iN+F62yQdSikGyUQVL+LaGNUgmzgK0zEahamfaMFep/9HE2FWuXlTCM+ZXx" \ - "OhGWUnjkGW9vi41/Turm7ALzaJoFm1f3Iv4nh1sRD1jySzlZvYwrq4LwmgZ8r0M+Q6xUSIIJfgS8Zjmp43strKo28vKT+DmUKu9Fg" \ - "jZWjW3S8WPPJFO0UqA0b1UQspmNLZOVxsNpa0OCM1pofJvT09n6xG+byV30Bed27Kw+D3fzfYq5xvohyeCyliTq8LHnOykecki3Y2" \ - "Pvl1qsxxBehlwc/WH8yIUiwC2Du6zY61tN3LGgMAoIFl40Roo1z/I7YfOy4ZCukOGqqyiLdjoXxIVQqqsPtKsrVXS+A9OQ+sVESgw" \ - "f8jeEIw/KXLVB/aEyrZJXQR1pBfqkOTCSnAfZVBSjJyxhanS/8iGmnRV5zz3auYMLR9aA8QHjV/VZOj0Bxhuba9VIzJlY9XoUt5Vs" \ - "h3uILJM3uVJzSjlZV+Jw3O+NdQFnZyh7m1+eJUMQJ8i0Sr3sMLsdb9me/I0HueXCa5eBHAoTtAyQgS4uN4NMhvpqrB/lQCx7pqnkt" \ - "xiCO/bUEZONQjWrvJT+EfD+I0UMFtPFiGDzJ0yi0Ah7LxSTGEGPFZHH5RgsJA8lJwGMCUtc9Cpy8A=" - -SIGNATURE3 = "hVdLwsWXe6yVy88m9H1903+Bj/DjSGsYL+ZIpEz+G6u/aVx6QfsvnWHzasjqN8SU+brHfL0c8KrapWcACO+jyCuXlHMZb9zKmJkHR" \ - "FSOiprCJ3tqNpv/4MIa9CXu0YDqnLHBSyxS01luKw3EqgpWPQdYcqDpOkjjTOq45dQC0PGHA/DXjP7LBptV9AwW200LIcL5Li8tDU" \ - "a8VSQybspDDfDpXU3+Xl5tJIBVS4ercPczp5B39Cwne4q2gyj/Y5RdIoX5RMqmFhfucw1he38T1oRC9AHTJqj4CBcDt7gc6jPHuzk" \ - "N7u1eUf0IK3+KTDKsCkkoHcGaoxT+NeWcS8Ki1A==" - -PUBKEY = "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuCfU1G5X+3O6vPdSz6QY\nSFbgdbv3KPv" \ - "xHi8tRmlyOLdLt5i1eqsy2WCW1iYNijiCL7OfbrvymBQxe3GA9S64\nVuavwzQ8nO7nzpNMqxY5tBXsBM1lECCHDOvm5dzINXWT9Sg7P1" \ - "8iIxE/2wQEgMUL\nAeVbJtAriXM4zydL7c91agFMJu1aHp0lxzoH8I13xzUetGMutR1tbcfWvoQvPAoU\n89uAz5j/DFMhWrkVEKGeWt1" \ - "YtHMmJqpYqR6961GDlwRuUsOBsLgLLVohzlBsTBSn\n3580o2E6G3DEaX0Az9WB9ylhNeV/L/PP3c5htpEyoPZSy1pgtut6TRYQwC8wns" \ - "qO\nbVIbFBkrKoaRDyVCnpMuKdDNLZqOOfhzas+SWRAby6D8VsXpPi/DpeS9XkX0o/uH\nJ9N49GuYMSUGC8gKtaddD13pUqS/9rpSvLD" \ - "rrDQe5Lhuyusgd28wgEAPCTmM3pEt\nQnlxEeEmFMIn3OBLbEDw5TFE7iED0z7a4dAkqqz8KCGEt12e1Kz7ujuOVMxJxzk6\nNtwt40Sq" \ - "EOPcdsGHAA+hqzJnXUihXfmtmFkropaCxM2f+Ha0bOQdDDui5crcV3sX\njShmcqN6YqFzmoPK0XM9P1qC+lfL2Mz6bHC5p9M8/FtcM46" \ - "hCj1TF/tl8zaZxtHP\nOrMuFJy4j4yAsyVy3ddO69ECAwEAAQ==\n-----END PUBLIC KEY-----\n" +from federation.protocols.diaspora.signatures import create_relayable_signature, verify_relayable_signature +from federation.tests.fixtures.keys import PUBKEY, SIGNATURE, SIGNATURE2, SIGNATURE3, XML, XML2, get_dummy_private_key def test_verify_relayable_signature(): diff --git a/federation/tests/test_fetchers.py b/federation/tests/test_fetchers.py index 9a2157a..fa60a91 100644 --- a/federation/tests/test_fetchers.py +++ b/federation/tests/test_fetchers.py @@ -1,16 +1,24 @@ -# -*- coding: utf-8 -*- from unittest.mock import patch, Mock -from federation.fetchers import retrieve_remote_profile +from federation.entities.base import Post +from federation.fetchers import retrieve_remote_profile, retrieve_remote_content -class TestRetrieveRemoteProfile(object): +class TestRetrieveRemoteContent: + @patch("federation.fetchers.importlib.import_module") + def test_calls_diaspora_retrieve_and_parse_content(self, mock_import): + mock_retrieve = Mock() + mock_import.return_value = mock_retrieve + retrieve_remote_content(Post, "1234@example.com", sender_key_fetcher=sum) + mock_retrieve.retrieve_and_parse_content.assert_called_once_with( + Post, "1234@example.com", sender_key_fetcher=sum, + ) + + +class TestRetrieveRemoteProfile: @patch("federation.fetchers.importlib.import_module") def test_calls_diaspora_retrieve_and_parse_profile(self, mock_import): - class MockRetrieve(Mock): - def retrieve_and_parse_profile(self, handle): - return "called with %s" % handle - - mock_retrieve = MockRetrieve() + mock_retrieve = Mock() mock_import.return_value = mock_retrieve - assert retrieve_remote_profile("foo@bar") == "called with foo@bar" + retrieve_remote_profile("foo@bar") + mock_retrieve.retrieve_and_parse_profile.assert_called_once_with("foo@bar") diff --git a/federation/tests/utils/test_diaspora.py b/federation/tests/utils/test_diaspora.py index 30d15ff..6d1b20f 100644 --- a/federation/tests/utils/test_diaspora.py +++ b/federation/tests/utils/test_diaspora.py @@ -1,17 +1,33 @@ -# -*- coding: utf-8 -*- import xml from unittest.mock import patch, Mock from urllib.parse import quote +import pytest from lxml import html -from federation.entities.base import Profile -from federation.hostmeta.generators import DiasporaWebFinger, DiasporaHostMeta, DiasporaHCard, generate_hcard -from federation.utils.diaspora import retrieve_diaspora_hcard, retrieve_diaspora_webfinger, retrieve_diaspora_host_meta, \ - _get_element_text_or_none, _get_element_attr_or_none, parse_profile_from_hcard, retrieve_and_parse_profile +from federation.entities.base import Profile, Post +from federation.hostmeta.generators import DiasporaWebFinger, DiasporaHostMeta, generate_hcard +from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD +from federation.utils.diaspora import ( + retrieve_diaspora_hcard, retrieve_diaspora_webfinger, retrieve_diaspora_host_meta, _get_element_text_or_none, + _get_element_attr_or_none, parse_profile_from_hcard, retrieve_and_parse_profile, retrieve_and_parse_content, + get_fetch_content_endpoint, fetch_public_key) -class TestRetrieveDiasporaHCard(object): +@patch("federation.utils.diaspora.retrieve_and_parse_profile", autospec=True) +def test_fetch_public_key(mock_retrieve): + mock_retrieve.return_value = Mock(public_key="public key") + result = fetch_public_key("spam@eggs") + mock_retrieve.assert_called_once_with("spam@eggs") + assert result == "public key" + + +def test_get_fetch_content_endpoint(): + assert get_fetch_content_endpoint("example.com", "status_message", "1234") == \ + "https://example.com/fetch/status_message/1234" + + +class TestRetrieveDiasporaHCard: @patch("federation.utils.diaspora.retrieve_diaspora_webfinger", return_value=None) def test_retrieve_webfinger_is_called(self, mock_retrieve): retrieve_diaspora_hcard("bob@localhost") @@ -40,7 +56,7 @@ class TestRetrieveDiasporaHCard(object): assert document == None -class TestRetrieveDiasporaWebfinger(object): +class TestRetrieveDiasporaWebfinger: @patch("federation.utils.diaspora.retrieve_diaspora_host_meta", return_value=None) def test_retrieve_host_meta_is_called(self, mock_retrieve): retrieve_diaspora_webfinger("bob@localhost") @@ -82,7 +98,7 @@ class TestRetrieveDiasporaWebfinger(object): assert document == None -class TestRetrieveDiasporaHostMeta(object): +class TestRetrieveDiasporaHostMeta: @patch("federation.utils.diaspora.XRD.parse_xrd") @patch("federation.utils.diaspora.fetch_document") def test_fetch_document_is_called(self, mock_fetch, mock_xrd): @@ -100,7 +116,48 @@ class TestRetrieveDiasporaHostMeta(object): assert document == None -class TestGetElementTextOrNone(object): +class TestRetrieveAndParseContent: + @patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None)) + @patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs") + def test_calls_fetch_document(self, mock_get, mock_fetch): + retrieve_and_parse_content(Post, "1234@example.com") + mock_fetch.assert_called_once_with("https://example.com/fetch/spam/eggs") + + @patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None)) + @patch("federation.utils.diaspora.get_fetch_content_endpoint") + def test_calls_get_fetch_content_endpoint(self, mock_get, mock_fetch): + retrieve_and_parse_content(Post, "1234@example.com") + mock_get.assert_called_once_with("example.com", "status_message", "1234") + mock_get.reset_mock() + retrieve_and_parse_content(Post, "fooobar@1234@example.com") + mock_get.assert_called_once_with("example.com", "status_message", "fooobar@1234") + + @patch("federation.utils.diaspora.fetch_document", return_value=(DIASPORA_PUBLIC_PAYLOAD, 200, None)) + @patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs") + @patch("federation.utils.diaspora.handle_receive", return_value=("sender", "protocol", ["entity"])) + def test_calls_handle_receive(self, mock_handle, mock_get, mock_fetch): + entity = retrieve_and_parse_content(Post, "1234@example.com", sender_key_fetcher=sum) + mock_handle.assert_called_once_with(DIASPORA_PUBLIC_PAYLOAD, sender_key_fetcher=sum) + assert entity == "entity" + + @patch("federation.utils.diaspora.fetch_document", return_value=(None, None, Exception())) + @patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs") + def test_raises_on_fetch_error(self, mock_get, mock_fetch): + with pytest.raises(Exception): + retrieve_and_parse_content(Post, "1234@example.com") + + def test_raises_on_unknown_entity(self): + with pytest.raises(ValueError): + retrieve_and_parse_content(dict, "1234@example.com") + + @patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None)) + @patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs") + def test_returns_on_404(self, mock_get, mock_fetch): + result = retrieve_and_parse_content(Post, "1234@example.com") + assert not result + + +class TestGetElementTextOrNone: doc = html.fromstring("bar") def test_text_returned_on_element(self): @@ -110,7 +167,7 @@ class TestGetElementTextOrNone(object): assert _get_element_text_or_none(self.doc, "bar") == None -class TestGetElementAttrOrNone(object): +class TestGetElementAttrOrNone: doc = html.fromstring("bar") def test_attr_returned_on_attr(self): @@ -123,7 +180,7 @@ class TestGetElementAttrOrNone(object): assert _get_element_attr_or_none(self.doc, "bar", "href") == None -class TestParseProfileFromHCard(object): +class TestParseProfileFromHCard: def test_profile_is_parsed(self): hcard = generate_hcard( "diaspora", @@ -151,7 +208,7 @@ class TestParseProfileFromHCard(object): profile.validate() -class TestRetrieveAndParseProfile(object): +class TestRetrieveAndParseProfile: @patch("federation.utils.diaspora.retrieve_diaspora_hcard", return_value=None) def test_retrieve_diaspora_hcard_is_called(self, mock_retrieve): retrieve_and_parse_profile("foo@bar") diff --git a/federation/utils/diaspora.py b/federation/utils/diaspora.py index f127699..c6d9c8f 100644 --- a/federation/utils/diaspora.py +++ b/federation/utils/diaspora.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- import logging import xml from urllib.parse import quote @@ -7,11 +6,22 @@ from lxml import html from xrd import XRD from federation.entities.base import Profile +from federation.inbound import handle_receive from federation.utils.network import fetch_document logger = logging.getLogger("federation") +def fetch_public_key(handle): + """Fetch public key over the network. + + :param handle: Remote handle to retrieve public key for. + :return: Public key in str format from parsed profile. + """ + profile = retrieve_and_parse_profile(handle) + return profile.public_key + + def retrieve_diaspora_hcard(handle): """ Retrieve a remote Diaspora hCard document. @@ -122,6 +132,43 @@ def parse_profile_from_hcard(hcard, handle): return profile +def retrieve_and_parse_content(entity_class, id, sender_key_fetcher=None): + """Retrieve remote content and return an Entity class instance. + + This is basically the inverse of receiving an entity. Instead, we fetch it, then call 'handle_receive'. + + :param entity_class: Federation entity class (from ``federation.entity.base``). + :param id: GUID and domain of the remote entity, in format``guid@domain.tld``. + :param sender_key_fetcher: Function to use to fetch sender public key. If not given, network will be used + to fetch the profile and the key. Function must take handle as only parameter and return a public key. + :returns: Entity object instance or ``None`` + :raises: ``ValueError`` if ``entity_class`` is not valid. + """ + from federation.entities.diaspora.mappers import BASE_MAPPINGS + entity_type = BASE_MAPPINGS.get(entity_class) + if not entity_type: + raise ValueError("Unknown entity_class %s" % entity_class) + guid, domain = id.rsplit("@", 1) + url = get_fetch_content_endpoint(domain, entity_type, guid) + document, status_code, error = fetch_document(url) + if status_code == 200: + _sender, _protocol, entities = handle_receive(document, sender_key_fetcher=sender_key_fetcher) + if len(entities) > 1: + logger.warning("retrieve_and_parse_content - more than one entity parsed from remote even though we" + "expected only one! ID %s", id) + if entities: + return entities[0] + return + elif status_code == 404: + logger.warning("retrieve_and_parse_content - remote content %s not found", id) + return + if error: + raise error + raise Exception("retrieve_and_parse_content - unknown problem when fetching document: %s, %s, %s" % ( + document, status_code, error, + )) + + def retrieve_and_parse_profile(handle): """ Retrieve the remote user and return a Profile object. @@ -142,5 +189,14 @@ def retrieve_and_parse_profile(handle): return profile +def get_fetch_content_endpoint(domain, entity_type, guid): + """Get remote fetch content endpoint. + + See: https://diaspora.github.io/diaspora_federation/federation/fetching.html + """ + return "https://%s/fetch/%s/%s" % (domain, entity_type, guid) + + def get_public_endpoint(domain): + """Get remote endpoint for delivering public payloads.""" return "https://%s/receive/public" % domain From a65b04096914ac8a329dbc0e1b992872e67485fd Mon Sep 17 00:00:00 2001 From: Jason Robinson Date: Sun, 22 Oct 2017 14:40:12 +0300 Subject: [PATCH 2/2] Support Diaspora URI scheme Add 'id' and 'target_id' to Diaspora entities. Refactor retrieve content fetcher to use the Diaspora URI scheme based ID. --- CHANGELOG.md | 19 +++-- docs/usage.rst | 1 + federation/entities/base.py | 16 +++++ federation/entities/diaspora/entities.py | 70 ++++++++++++++++--- federation/entities/diaspora/mappers.py | 19 ++--- federation/fetchers.py | 7 +- federation/tests/conftest.py | 14 +--- .../tests/entities/diaspora/test_entities.py | 36 ++++++++++ federation/tests/fixtures/diaspora.py | 68 ++++++++++++++++++ federation/tests/test_fetchers.py | 5 +- federation/tests/utils/test_diaspora.py | 32 +++++---- federation/utils/diaspora.py | 29 +++++--- 12 files changed, 243 insertions(+), 73 deletions(-) create mode 100644 federation/tests/fixtures/diaspora.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa8436..fb6c6c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,27 +6,32 @@ * Added base entity `Share` which maps to a `DiasporaReshare` for the Diaspora protocol. ([related issue](https://github.com/jaywink/federation/issues/94)) The `Share` entity supports all the properties that a Diaspora reshare does. Additionally two other properties are supported: `raw_content` and `entity_type`. The former can be used for a "quoted share" case where the sharer adds their own note to the share. The latter can be used to reference the type of object that was shared, to help the receiver, if it is not sharing a `Post` entity. The value must be a base entity class name. - + +* Entities have two new properties: `id` and `target_id`. + + Diaspora entity ID's are in the form of the [Diaspora URI scheme](https://diaspora.github.io/diaspora_federation/federation/diaspora_scheme.html), where it is possible to construct an ID from the entity. In the future, ActivityPub object ID's will be found in these properties. + * New high level fetcher function `federation.fetchers.retrieve_remote_content`. ([related issue](https://github.com/jaywink/federation/issues/103)) This function takes the following parameters: - * `entity_class` - Base entity class to fetch (for example `Post`). - * `id` - Object ID. Currently since only Diaspora is supported and the ID is expected to be in format `@`. + * `id` - Object ID. For Diaspora, the only supported protocol at the moment, this is in the [Diaspora URI](https://diaspora.github.io/diaspora_federation/federation/diaspora_scheme.html) format. * `sender_key_fetcher` - Optional function that takes a profile `handle` and returns a public key in `str` format. If this is not given, the public key will be fetched from the remote profile over the network. - The given ID will be fetched using the correct entity class specific remote endpoint, validated to be from the correct author against their public key and then an instance of the entity class will be constructed and returned. + The given ID will be fetched from the remote endpoint, validated to be from the correct author against their public key and then an instance of the entity class will be constructed and returned. -* New Diaspora protocol helper `federation.utils.diaspora.retrieve_and_parse_content`. See notes regarding the high level fetcher above. +* New Diaspora protocol helpers in `federation.utils.diaspora`: -* New Diaspora protocol helper `federation.utils.fetch_public_key`. Given a `handle` as a parameter, will fetch the remote profile and return the `public_key` from it. + * `retrieve_and_parse_content`. See notes regarding the high level fetcher above. + * `fetch_public_key`. Given a `handle` as a parameter, will fetch the remote profile and return the `public_key` from it. + * `parse_diaspora_uri`. Parses a Diaspora URI scheme string, returns either `None` if parsing fails or a `tuple` of `handle`, `entity_type` and `guid`. ### Changed * Refactoring for Diaspora `MagicEnvelope` class. The class init now also allows passing in parameters to construct and verify MagicEnvelope instances. The order of init parameters has not been changed, but they are now all optional. When creating a class instance, one should always pass in the necessary parameters depnding on whether the class instance will be used for building a payload or verifying an incoming payload. See class docstring for details. -* Diaspora procotol receive flow now uses the `MagicEnvelope` class to verify payloads. +* Diaspora procotol receive flow now uses the `MagicEnvelope` class to verify payloads. No functional changes regarding verification otherwise. * Diaspora protocol receive flow now fetches the sender public key over the network if a `sender_key_fetcher` function is not passed in. Previously an error would be raised. diff --git a/docs/usage.rst b/docs/usage.rst index ce15bdd..d599c11 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -109,6 +109,7 @@ Diaspora .. autofunction:: federation.utils.diaspora.fetch_public_key .. autofunction:: federation.utils.diaspora.get_fetch_content_endpoint .. autofunction:: federation.utils.diaspora.get_public_endpoint +.. autofunction:: federation.utils.diaspora.parse_diaspora_uri .. autofunction:: federation.utils.diaspora.parse_profile_from_hcard .. autofunction:: federation.utils.diaspora.retrieve_and_parse_content .. autofunction:: federation.utils.diaspora.retrieve_and_parse_profile diff --git a/federation/entities/base.py b/federation/entities/base.py index b8f8ba5..c0b5bcf 100644 --- a/federation/entities/base.py +++ b/federation/entities/base.py @@ -30,6 +30,14 @@ class BaseEntity: self.__class__.__name__, key )) + @property + def id(self): + """Global network ID. + + Future expansion: Convert later into an attribute which with ActivityPub will have the 'id' directly. + """ + return + def validate(self): """Do validation. @@ -120,6 +128,14 @@ class TargetGUIDMixin(BaseEntity): super().__init__(*args, **kwargs) self._required += ["target_guid"] + @property + def target_id(self): + """Global network target ID. + + Future expansion: convert to attribute when ActivityPub is supported. + """ + return + def validate_target_guid(self): if len(self.target_guid) < 16: raise ValueError("Target GUID must be at least 16 characters") diff --git a/federation/entities/diaspora/entities.py b/federation/entities/diaspora/entities.py index d7392aa..ade6da1 100644 --- a/federation/entities/diaspora/entities.py +++ b/federation/entities/diaspora/entities.py @@ -1,17 +1,58 @@ +import importlib + from lxml import etree from federation.entities.base import ( - Comment, Post, Reaction, Relationship, Profile, Retraction, BaseEntity, Follow, Share) + Comment, Post, Reaction, Relationship, Profile, Retraction, BaseEntity, Follow, Share, Image, +) from federation.entities.diaspora.utils import format_dt, struct_to_xml, get_base_attributes, add_element_to_doc from federation.exceptions import SignatureVerificationError from federation.protocols.diaspora.signatures import verify_relayable_signature, create_relayable_signature from federation.utils.diaspora import retrieve_and_parse_profile +CLASS_TO_TAG_MAPPING = { + Comment: "comment", + Follow: "contact", + Image: "photo", + Post: "status_message", + Profile: "profile", + Reaction: "like", + Relationship: "request", + Retraction: "retraction", + Share: "reshare", +} + class DiasporaEntityMixin(BaseEntity): # Normally outbound document is generated from entity. Store one here if at some point we already have a doc outbound_doc = None + @property + def id(self): + """Diaspora URI scheme format ID. + + Only available for entities that own a handle and a guid. + """ + try: + # noinspection PyUnresolvedReferences + return "diaspora://%s/%s/%s" % (self.handle, self._tag_name, self.guid) + except AttributeError: + return None + + # noinspection PyUnresolvedReferences + @property + def target_id(self): + """Diaspora URI scheme format target ID. + + Only available for entities that own a target_handle, target_guid and entity_type. + """ + try: + cls_module = importlib.import_module("federation.entities.base") + cls = getattr(cls_module, self.entity_type) + return "diaspora://%s/%s/%s" % (self.target_handle, CLASS_TO_TAG_MAPPING[cls], self.target_guid) + except (AttributeError, ImportError, KeyError): + return None + def to_xml(self): """Override in subclasses.""" raise NotImplementedError @@ -66,8 +107,10 @@ class DiasporaRelayableMixin(DiasporaEntityMixin): class DiasporaComment(DiasporaRelayableMixin, Comment): """Diaspora comment.""" + _tag_name = "comment" + def to_xml(self): - element = etree.Element("comment") + element = etree.Element(self._tag_name) struct_to_xml(element, [ {"guid": self.guid}, {"parent_guid": self.target_guid}, @@ -82,9 +125,11 @@ class DiasporaComment(DiasporaRelayableMixin, Comment): class DiasporaPost(DiasporaEntityMixin, Post): """Diaspora post, ie status message.""" + _tag_name = "status_message" + def to_xml(self): """Convert to XML message.""" - element = etree.Element("status_message") + element = etree.Element(self._tag_name) struct_to_xml(element, [ {"raw_message": self.raw_content}, {"guid": self.guid}, @@ -98,11 +143,12 @@ class DiasporaPost(DiasporaEntityMixin, Post): class DiasporaLike(DiasporaRelayableMixin, Reaction): """Diaspora like.""" + _tag_name = "like" reaction = "like" def to_xml(self): """Convert to XML message.""" - element = etree.Element("like") + element = etree.Element(self._tag_name) struct_to_xml(element, [ {"target_type": "Post"}, {"guid": self.guid}, @@ -117,11 +163,12 @@ class DiasporaLike(DiasporaRelayableMixin, Reaction): class DiasporaRequest(DiasporaEntityMixin, Relationship): """Diaspora legacy request.""" + _tag_name = "request" relationship = "sharing" def to_xml(self): """Convert to XML message.""" - element = etree.Element("request") + element = etree.Element(self._tag_name) struct_to_xml(element, [ {"sender_handle": self.handle}, {"recipient_handle": self.target_handle}, @@ -134,10 +181,11 @@ class DiasporaContact(DiasporaEntityMixin, Follow): Note we don't implement 'sharing' at the moment so just send it as the same as 'following'. """ + _tag_name = "contact" def to_xml(self): """Convert to XML message.""" - element = etree.Element("contact") + element = etree.Element(self._tag_name) struct_to_xml(element, [ {"author": self.handle}, {"recipient": self.target_handle}, @@ -149,10 +197,11 @@ class DiasporaContact(DiasporaEntityMixin, Follow): class DiasporaProfile(DiasporaEntityMixin, Profile): """Diaspora profile.""" + _tag_name = "profile" def to_xml(self): """Convert to XML message.""" - element = etree.Element("profile") + element = etree.Element(self._tag_name) struct_to_xml(element, [ {"diaspora_handle": self.handle}, {"first_name": self.name}, @@ -181,6 +230,7 @@ class DiasporaProfile(DiasporaEntityMixin, Profile): class DiasporaRetraction(DiasporaEntityMixin, Retraction): """Diaspora Retraction.""" + _tag_name = "retraction" mapped = { "Like": "Reaction", "Photo": "Image", @@ -189,7 +239,7 @@ class DiasporaRetraction(DiasporaEntityMixin, Retraction): def to_xml(self): """Convert to XML message.""" - element = etree.Element("retraction") + element = etree.Element(self._tag_name) struct_to_xml(element, [ {"author": self.handle}, {"target_guid": self.target_guid}, @@ -216,8 +266,10 @@ class DiasporaRetraction(DiasporaEntityMixin, Retraction): class DiasporaReshare(DiasporaEntityMixin, Share): """Diaspora Reshare.""" + _tag_name = "reshare" + def to_xml(self): - element = etree.Element("reshare") + element = etree.Element(self._tag_name) struct_to_xml(element, [ {"author": self.handle}, {"guid": self.guid}, diff --git a/federation/entities/diaspora/mappers.py b/federation/entities/diaspora/mappers.py index 79f8ba3..e9e2884 100644 --- a/federation/entities/diaspora/mappers.py +++ b/federation/entities/diaspora/mappers.py @@ -3,27 +3,16 @@ from datetime import datetime from lxml import etree -from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile, Retraction, Follow, Share +from federation.entities.base import Comment, Follow, Image, Post, Profile, Reaction, Relationship, Retraction, Share from federation.entities.diaspora.entities import ( - DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction, - DiasporaRelayableMixin, DiasporaContact, DiasporaReshare) + DiasporaComment, DiasporaContact, DiasporaLike, DiasporaPost, + DiasporaProfile, DiasporaRelayableMixin, DiasporaRequest, DiasporaReshare, DiasporaRetraction, +) from federation.protocols.diaspora.signatures import get_element_child_info from federation.utils.diaspora import retrieve_and_parse_profile logger = logging.getLogger("federation") -BASE_MAPPINGS = { - Comment: "comment", - Follow: "contact", - Image: "photo", - Post: "status_message", - Profile: "profile", - Reaction: "like", - Relationship: "request", - Retraction: "retraction", - Share: "reshare", -} - MAPPINGS = { "status_message": DiasporaPost, "photo": Image, diff --git a/federation/fetchers.py b/federation/fetchers.py index 12d830b..a32a7bf 100644 --- a/federation/fetchers.py +++ b/federation/fetchers.py @@ -1,20 +1,19 @@ import importlib -def retrieve_remote_content(entity_class, id, sender_key_fetcher=None): +def retrieve_remote_content(id, sender_key_fetcher=None): """Retrieve remote content and return an Entity object. Currently, due to no other protocols supported, always use the Diaspora protocol. - :param entity_class: Federation entity class (from ``federation.entity.base``). - :param id: ID of the remote entity, in format``guid@domain.tld``. + :param id: ID of the remote entity. :param sender_key_fetcher: Function to use to fetch sender public key. If not given, network will be used to fetch the profile and the key. Function must take handle as only parameter and return a public key. :returns: Entity class instance or ``None`` """ protocol_name = "diaspora" utils = importlib.import_module("federation.utils.%s" % protocol_name) - return utils.retrieve_and_parse_content(entity_class, id, sender_key_fetcher=sender_key_fetcher) + return utils.retrieve_and_parse_content(id, sender_key_fetcher=sender_key_fetcher) def retrieve_remote_profile(handle): diff --git a/federation/tests/conftest.py b/federation/tests/conftest.py index 3523317..3bdf54d 100644 --- a/federation/tests/conftest.py +++ b/federation/tests/conftest.py @@ -2,9 +2,9 @@ from unittest.mock import Mock import pytest -from federation.entities.diaspora.entities import DiasporaPost +# noinspection PyUnresolvedReferences +from federation.tests.fixtures.diaspora import * from federation.tests.fixtures.keys import get_dummy_private_key -from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD @pytest.fixture(autouse=True) @@ -23,16 +23,6 @@ def disable_network_calls(monkeypatch): monkeypatch.setattr("requests.get", Mock(return_value=MockResponse)) -@pytest.fixture -def diaspora_public_payload(): - return DIASPORA_PUBLIC_PAYLOAD - - -@pytest.fixture -def diasporapost(): - return DiasporaPost() - - @pytest.fixture def private_key(): return get_dummy_private_key() diff --git a/federation/tests/entities/diaspora/test_entities.py b/federation/tests/entities/diaspora/test_entities.py index dea34c1..0d4e3d1 100644 --- a/federation/tests/entities/diaspora/test_entities.py +++ b/federation/tests/entities/diaspora/test_entities.py @@ -110,6 +110,42 @@ class TestEntitiesConvertToXML: assert etree.tostring(result).decode("utf-8") == converted +class TestEntityAttributes: + def test_comment_ids(self, diasporacomment): + assert diasporacomment.id == "diaspora://handle/comment/guid" + assert not diasporacomment.target_id + + def test_contact_ids(self, diasporacontact): + assert not diasporacontact.id + assert not diasporacontact.target_id + + def test_like_ids(self, diasporalike): + assert diasporalike.id == "diaspora://handle/like/guid" + assert not diasporalike.target_id + + def test_post_ids(self, diasporapost): + assert diasporapost.id == "diaspora://handle/status_message/guid" + assert not diasporapost.target_id + + def test_profile_ids(self, diasporaprofile): + assert diasporaprofile.id == "diaspora://bob@example.com/profile/" + assert not diasporaprofile.target_id + + def test_request_ids(self, diasporarequest): + assert not diasporarequest.id + assert not diasporarequest.target_id + + def test_reshare_ids(self, diasporareshare): + assert diasporareshare.id == "diaspora://%s/reshare/%s" % (diasporareshare.handle, diasporareshare.guid) + assert diasporareshare.target_id == "diaspora://%s/status_message/%s" % ( + diasporareshare.target_handle, diasporareshare.target_guid + ) + + def test_retraction_ids(self, diasporaretraction): + assert not diasporaretraction.id + assert not diasporaretraction.target_id + + class TestDiasporaProfileFillExtraAttributes: def test_raises_if_no_handle(self): attrs = {"foo": "bar"} diff --git a/federation/tests/fixtures/diaspora.py b/federation/tests/fixtures/diaspora.py new file mode 100644 index 0000000..881a158 --- /dev/null +++ b/federation/tests/fixtures/diaspora.py @@ -0,0 +1,68 @@ +import pytest + +from federation.entities.diaspora.entities import ( + DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction, + DiasporaContact, DiasporaReshare, +) +from federation.tests.factories.entities import ShareFactory +from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD + +__all__ = ("diasporacomment", "diasporacontact", "diasporalike", "diasporapost", "diasporaprofile", + "diasporareshare", "diasporarequest", "diasporaretraction", "diaspora_public_payload") + + +@pytest.fixture +def diaspora_public_payload(): + return DIASPORA_PUBLIC_PAYLOAD + + +@pytest.fixture +def diasporacomment(): + return DiasporaComment( + raw_content="raw_content", guid="guid", target_guid="target_guid", handle="handle", + signature="signature" + ) + + +@pytest.fixture +def diasporacontact(): + return DiasporaContact(handle="alice@example.com", target_handle="bob@example.org", following=True) + + +@pytest.fixture +def diasporalike(): + return DiasporaLike(guid="guid", target_guid="target_guid", handle="handle", signature="signature") + + +@pytest.fixture +def diasporapost(): + return DiasporaPost( + raw_content="raw_content", guid="guid", handle="handle", public=True, + provider_display_name="Socialhome" + ) + + +@pytest.fixture +def diasporaprofile(): + return DiasporaProfile( + handle="bob@example.com", raw_content="foobar", name="Bob Bobertson", public=True, + tag_list=["socialfederation", "federation"], image_urls={ + "large": "urllarge", "medium": "urlmedium", "small": "urlsmall" + } + ) + + +@pytest.fixture +def diasporareshare(): + base_entity = ShareFactory() + return DiasporaReshare.from_base(base_entity) + + +@pytest.fixture +def diasporarequest(): + return DiasporaRequest(handle="bob@example.com", target_handle="alice@example.com", relationship="following") + + +@pytest.fixture +def diasporaretraction(): + return DiasporaRetraction(handle="bob@example.com", target_guid="x" * 16, entity_type="Post") diff --git a/federation/tests/test_fetchers.py b/federation/tests/test_fetchers.py index fa60a91..c886e38 100644 --- a/federation/tests/test_fetchers.py +++ b/federation/tests/test_fetchers.py @@ -1,6 +1,5 @@ from unittest.mock import patch, Mock -from federation.entities.base import Post from federation.fetchers import retrieve_remote_profile, retrieve_remote_content @@ -9,9 +8,9 @@ class TestRetrieveRemoteContent: def test_calls_diaspora_retrieve_and_parse_content(self, mock_import): mock_retrieve = Mock() mock_import.return_value = mock_retrieve - retrieve_remote_content(Post, "1234@example.com", sender_key_fetcher=sum) + retrieve_remote_content("diaspora://user@example.com/status_message/1234", sender_key_fetcher=sum) mock_retrieve.retrieve_and_parse_content.assert_called_once_with( - Post, "1234@example.com", sender_key_fetcher=sum, + "diaspora://user@example.com/status_message/1234", sender_key_fetcher=sum, ) diff --git a/federation/tests/utils/test_diaspora.py b/federation/tests/utils/test_diaspora.py index 6d1b20f..4916c14 100644 --- a/federation/tests/utils/test_diaspora.py +++ b/federation/tests/utils/test_diaspora.py @@ -5,13 +5,14 @@ from urllib.parse import quote import pytest from lxml import html -from federation.entities.base import Profile, Post +from federation.entities.base import Profile from federation.hostmeta.generators import DiasporaWebFinger, DiasporaHostMeta, generate_hcard from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD from federation.utils.diaspora import ( retrieve_diaspora_hcard, retrieve_diaspora_webfinger, retrieve_diaspora_host_meta, _get_element_text_or_none, _get_element_attr_or_none, parse_profile_from_hcard, retrieve_and_parse_profile, retrieve_and_parse_content, - get_fetch_content_endpoint, fetch_public_key) + get_fetch_content_endpoint, fetch_public_key, parse_diaspora_uri, +) @patch("federation.utils.diaspora.retrieve_and_parse_profile", autospec=True) @@ -27,6 +28,13 @@ def test_get_fetch_content_endpoint(): "https://example.com/fetch/status_message/1234" +def test_parse_diaspora_uri(): + assert parse_diaspora_uri("diaspora://user@example.com/spam/eggs") == ("user@example.com", "spam", "eggs") + assert parse_diaspora_uri("diaspora://user@example.com/spam/eggs@spam") == ("user@example.com", "spam", "eggs@spam") + assert not parse_diaspora_uri("https://user@example.com/spam/eggs") + assert not parse_diaspora_uri("spam and eggs") + + class TestRetrieveDiasporaHCard: @patch("federation.utils.diaspora.retrieve_diaspora_webfinger", return_value=None) def test_retrieve_webfinger_is_called(self, mock_retrieve): @@ -120,23 +128,23 @@ class TestRetrieveAndParseContent: @patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None)) @patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs") def test_calls_fetch_document(self, mock_get, mock_fetch): - retrieve_and_parse_content(Post, "1234@example.com") + retrieve_and_parse_content("diaspora://user@example.com/spam/eggs") mock_fetch.assert_called_once_with("https://example.com/fetch/spam/eggs") @patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None)) @patch("federation.utils.diaspora.get_fetch_content_endpoint") def test_calls_get_fetch_content_endpoint(self, mock_get, mock_fetch): - retrieve_and_parse_content(Post, "1234@example.com") - mock_get.assert_called_once_with("example.com", "status_message", "1234") + retrieve_and_parse_content("diaspora://user@example.com/spam/eggs") + mock_get.assert_called_once_with("example.com", "spam", "eggs") mock_get.reset_mock() - retrieve_and_parse_content(Post, "fooobar@1234@example.com") - mock_get.assert_called_once_with("example.com", "status_message", "fooobar@1234") + retrieve_and_parse_content("diaspora://user@example.com/spam/eggs@spam") + mock_get.assert_called_once_with("example.com", "spam", "eggs@spam") @patch("federation.utils.diaspora.fetch_document", return_value=(DIASPORA_PUBLIC_PAYLOAD, 200, None)) @patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs") @patch("federation.utils.diaspora.handle_receive", return_value=("sender", "protocol", ["entity"])) def test_calls_handle_receive(self, mock_handle, mock_get, mock_fetch): - entity = retrieve_and_parse_content(Post, "1234@example.com", sender_key_fetcher=sum) + entity = retrieve_and_parse_content("diaspora://user@example.com/spam/eggs", sender_key_fetcher=sum) mock_handle.assert_called_once_with(DIASPORA_PUBLIC_PAYLOAD, sender_key_fetcher=sum) assert entity == "entity" @@ -144,16 +152,12 @@ class TestRetrieveAndParseContent: @patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs") def test_raises_on_fetch_error(self, mock_get, mock_fetch): with pytest.raises(Exception): - retrieve_and_parse_content(Post, "1234@example.com") - - def test_raises_on_unknown_entity(self): - with pytest.raises(ValueError): - retrieve_and_parse_content(dict, "1234@example.com") + retrieve_and_parse_content("diaspora://user@example.com/spam/eggs") @patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None)) @patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs") def test_returns_on_404(self, mock_get, mock_fetch): - result = retrieve_and_parse_content(Post, "1234@example.com") + result = retrieve_and_parse_content("diaspora://user@example.com/spam/eggs") assert not result diff --git a/federation/utils/diaspora.py b/federation/utils/diaspora.py index c6d9c8f..b5efebb 100644 --- a/federation/utils/diaspora.py +++ b/federation/utils/diaspora.py @@ -108,6 +108,22 @@ def _get_element_attr_or_none(document, selector, attribute): return None +def parse_diaspora_uri(uri): + """Parse Diaspora URI scheme string. + + See: https://diaspora.github.io/diaspora_federation/federation/diaspora_scheme.html + + :return: tuple of (handle, entity_type, guid) or ``None``. + """ + if not uri.startswith("diaspora://"): + return + try: + handle, entity_type, guid = uri.replace("diaspora://", "").rsplit("/", maxsplit=2) + except ValueError: + return + return handle, entity_type, guid + + def parse_profile_from_hcard(hcard, handle): """ Parse all the fields we can from a hCard document to get a Profile. @@ -132,23 +148,18 @@ def parse_profile_from_hcard(hcard, handle): return profile -def retrieve_and_parse_content(entity_class, id, sender_key_fetcher=None): +def retrieve_and_parse_content(id, sender_key_fetcher=None): """Retrieve remote content and return an Entity class instance. This is basically the inverse of receiving an entity. Instead, we fetch it, then call 'handle_receive'. - :param entity_class: Federation entity class (from ``federation.entity.base``). - :param id: GUID and domain of the remote entity, in format``guid@domain.tld``. + :param id: Diaspora URI scheme format ID. :param sender_key_fetcher: Function to use to fetch sender public key. If not given, network will be used to fetch the profile and the key. Function must take handle as only parameter and return a public key. :returns: Entity object instance or ``None`` - :raises: ``ValueError`` if ``entity_class`` is not valid. """ - from federation.entities.diaspora.mappers import BASE_MAPPINGS - entity_type = BASE_MAPPINGS.get(entity_class) - if not entity_type: - raise ValueError("Unknown entity_class %s" % entity_class) - guid, domain = id.rsplit("@", 1) + handle, entity_type, guid = parse_diaspora_uri(id) + _username, domain = handle.split("@") url = get_fetch_content_endpoint(domain, entity_type, guid) document, status_code, error = fetch_document(url) if status_code == 200: