Repository: jaywink/federation

Issue:
Method to fetch remote content
For example posts, reshares and comments. Do a fetch against the correct protocol endpoint and return a validated entity.

Related to jaywink/socialhome#206

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19755b4..fb6c6c0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,36 @@
 * 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:
+  
+    * `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 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 helpers in `federation.utils.diaspora`:
+
+  * `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. 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.
+
+  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..d599c11 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,12 @@ 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_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
 .. autofunction:: federation.utils.diaspora.retrieve_diaspora_hcard
 .. autofunction:: federation.utils.diaspora.retrieve_diaspora_webfinger
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 5acd205..e9e2884 100644
--- a/federation/entities/diaspora/mappers.py
+++ b/federation/entities/diaspora/mappers.py
@@ -3,10 +3,11 @@ 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
 
diff --git a/federation/fetchers.py b/federation/fetchers.py
index 46b9a5d..a32a7bf 100644
--- a/federation/fetchers.py
+++ b/federation/fetchers.py
@@ -1,7 +1,21 @@
-# -*- coding: utf-8 -*-
 import importlib
 
 
+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 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(id, sender_key_fetcher=sender_key_fetcher)
+
+
 def retrieve_remote_profile(handle):
     """High level retrieve profile method.
 
@@ -10,7 +24,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 <XML><post></post></XML>.
-                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 <XML><post></post></XML>.
+            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/utils/diaspora.py b/federation/utils/diaspora.py
index f127699..b5efebb 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.
@@ -98,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.
@@ -122,6 +148,38 @@ def parse_profile_from_hcard(hcard, handle):
     return profile
 
 
+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 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``
+    """
+    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:
+        _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 +200,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


Test patch:
diff --git a/federation/tests/conftest.py b/federation/tests/conftest.py
index e2bd947..3bdf54d 100644
--- a/federation/tests/conftest.py
+++ b/federation/tests/conftest.py
@@ -2,7 +2,8 @@ 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
 
 
@@ -23,10 +24,10 @@ def disable_network_calls(monkeypatch):
 
 
 @pytest.fixture
-def diasporapost():
-    return DiasporaPost()
+def private_key():
+    return get_dummy_private_key()
 
 
 @pytest.fixture
-def private_key():
-    return get_dummy_private_key()
+def public_key(private_key):
+    return private_key.publickey().exportKey()
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/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 = "<comment><guid>0dd40d800db1013514416c626dd55703</guid><parent_guid>69ab2b83-aa69-4456-ad0a-dd669" \
+      "7f54714</parent_guid><text>Woop Woop</text><diaspora_handle>jaywink@iliketoast.net</diaspora_handle></comment>"
+
+XML2 = "<comment><guid>d728fe501584013514526c626dd55703</guid><parent_guid>d641bd35-8142-414e-a12d-f956cc2c1bb9" \
+       "</parent_guid><text>What about the mystical problem with &#x1F44D; (pt2 with more logging)</text>" \
+       "<diaspora_handle>jaywink@iliketoast.net</diaspora_handle></comment>"
+
 
 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="<status_message><foo>bar</foo></status_message>",
@@ -40,11 +35,74 @@ class TestMagicEnvelope():
         env = MagicEnvelope(
             message="<status_message><foo>bar</foo></status_message>",
             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"<status_message><foo>bar</foo></status_message>"
+
+    @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"<status_message><foo>bar</foo></status_message>"
+
+    def test_verify(self, private_key, public_key):
+        me = MagicEnvelope(
+            message="<status_message><foo>bar</foo></status_message>",
+            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="<status_message><foo>bar</foo></status_message>",
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 = "<comment><guid>0dd40d800db1013514416c626dd55703</guid><parent_guid>69ab2b83-aa69-4456-ad0a-dd669" \
-      "7f54714</parent_guid><text>Woop Woop</text><diaspora_handle>jaywink@iliketoast.net</diaspora_handle></comment>"
-
-XML2 = "<comment><guid>d728fe501584013514526c626dd55703</guid><parent_guid>d641bd35-8142-414e-a12d-f956cc2c1bb9" \
-       "</parent_guid><text>What about the mystical problem with &#x1F44D; (pt2 with more logging)</text>" \
-       "<diaspora_handle>jaywink@iliketoast.net</diaspora_handle></comment>"
-
-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..c886e38 100644
--- a/federation/tests/test_fetchers.py
+++ b/federation/tests/test_fetchers.py
@@ -1,16 +1,23 @@
-# -*- coding: utf-8 -*-
 from unittest.mock import patch, Mock
 
-from federation.fetchers import retrieve_remote_profile
+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_profile(self, mock_import):
-        class MockRetrieve(Mock):
-            def retrieve_and_parse_profile(self, handle):
-                return "called with %s" % handle
+    def test_calls_diaspora_retrieve_and_parse_content(self, mock_import):
+        mock_retrieve = Mock()
+        mock_import.return_value = mock_retrieve
+        retrieve_remote_content("diaspora://user@example.com/status_message/1234", sender_key_fetcher=sum)
+        mock_retrieve.retrieve_and_parse_content.assert_called_once_with(
+            "diaspora://user@example.com/status_message/1234", sender_key_fetcher=sum,
+        )
+
 
-        mock_retrieve = MockRetrieve()
+class TestRetrieveRemoteProfile:
+    @patch("federation.fetchers.importlib.import_module")
+    def test_calls_diaspora_retrieve_and_parse_profile(self, mock_import):
+        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..4916c14 100644
--- a/federation/tests/utils/test_diaspora.py
+++ b/federation/tests/utils/test_diaspora.py
@@ -1,17 +1,41 @@
-# -*- 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.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, parse_diaspora_uri,
+)
 
 
-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"
+
+
+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):
         retrieve_diaspora_hcard("bob@localhost")
@@ -40,7 +64,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 +106,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 +124,44 @@ 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("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("diaspora://user@example.com/spam/eggs")
+        mock_get.assert_called_once_with("example.com", "spam", "eggs")
+        mock_get.reset_mock()
+        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("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"
+
+    @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("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("diaspora://user@example.com/spam/eggs")
+        assert not result
+
+
+class TestGetElementTextOrNone:
     doc = html.fromstring("<foo>bar</foo>")
 
     def test_text_returned_on_element(self):
@@ -110,7 +171,7 @@ class TestGetElementTextOrNone(object):
         assert _get_element_text_or_none(self.doc, "bar") == None
 
 
-class TestGetElementAttrOrNone(object):
+class TestGetElementAttrOrNone:
     doc = html.fromstring("<foo src='baz'>bar</foo>")
 
     def test_attr_returned_on_attr(self):
@@ -123,7 +184,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 +212,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")