Merge pull request #104 from jaywink/fetch-entities

New high level fetcher function retrieve_remote_content
merge-requests/130/head
Jason Robinson 2017-10-22 22:36:14 +03:00 zatwierdzone przez GitHub
commit 3157290093
18 zmienionych plików z 637 dodań i 125 usunięć

Wyświetl plik

@ -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.

Wyświetl plik

@ -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

Wyświetl plik

@ -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")

Wyświetl plik

@ -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},

Wyświetl plik

@ -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

Wyświetl plik

@ -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"

Wyświetl plik

@ -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")

Wyświetl plik

@ -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):
"""

Wyświetl plik

@ -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()

Wyświetl plik

@ -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"}

Wyświetl plik

@ -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")

Wyświetl plik

@ -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)

Wyświetl plik

@ -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>",

Wyświetl plik

@ -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()

Wyświetl plik

@ -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():

Wyświetl plik

@ -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_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,
)
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")

Wyświetl plik

@ -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")

Wyświetl plik

@ -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