New high level fetcher function retrieve_remote_content

The given ID will be fetched using the correct entity class specific remote endpoint, validated to be from the correct author against their public key and then an instance of the entity class will be constructed and returned.

Also related changes and refactoring:

* New Diaspora protocol helper `federation.utils.diaspora.retrieve_and_parse_content`. See notes regarding the high level fetcher above.
* New Diaspora protocol helper `federation.utils.fetch_public_key`. Given a `handle` as a parameter, will fetch the remote profile and return the `public_key` from it.
* Refactoring for Diaspora `MagicEnvelope` class.
* Diaspora procotol receive flow now uses the `MagicEnvelope` class to verify payloads.
* Diaspora protocol receive flow now fetches the sender public key over the network if a `sender_key_fetcher` function is not passed in. Previously an error would be raised.

Closes #103
merge-requests/130/head
Jason Robinson 2017-10-21 01:31:56 +03:00
rodzic bcc779e006
commit e343369f5b
14 zmienionych plików z 451 dodań i 109 usunięć

Wyświetl plik

@ -6,6 +6,31 @@
* Added base entity `Share` which maps to a `DiasporaReshare` for the Diaspora protocol. ([related issue](https://github.com/jaywink/federation/issues/94))
The `Share` entity supports all the properties that a Diaspora reshare does. Additionally two other properties are supported: `raw_content` and `entity_type`. The former can be used for a "quoted share" case where the sharer adds their own note to the share. The latter can be used to reference the type of object that was shared, to help the receiver, if it is not sharing a `Post` entity. The value must be a base entity class name.
* New high level fetcher function `federation.fetchers.retrieve_remote_content`. ([related issue](https://github.com/jaywink/federation/issues/103))
This function takes the following parameters:
* `entity_class` - Base entity class to fetch (for example `Post`).
* `id` - Object ID. Currently since only Diaspora is supported and the ID is expected to be in format `<guid>@<domain.tld>`.
* `sender_key_fetcher` - Optional function that takes a profile `handle` and returns a public key in `str` format. If this is not given, the public key will be fetched from the remote profile over the network.
The given ID will be fetched using the correct entity class specific remote endpoint, validated to be from the correct author against their public key and then an instance of the entity class will be constructed and returned.
* New Diaspora protocol helper `federation.utils.diaspora.retrieve_and_parse_content`. See notes regarding the high level fetcher above.
* New Diaspora protocol helper `federation.utils.fetch_public_key`. Given a `handle` as a parameter, will fetch the remote profile and return the `public_key` from it.
### Changed
* Refactoring for Diaspora `MagicEnvelope` class.
The class init now also allows passing in parameters to construct and verify MagicEnvelope instances. The order of init parameters has not been changed, but they are now all optional. When creating a class instance, one should always pass in the necessary parameters depnding on whether the class instance will be used for building a payload or verifying an incoming payload. See class docstring for details.
* Diaspora procotol receive flow now uses the `MagicEnvelope` class to verify payloads.
* Diaspora protocol receive flow now fetches the sender public key over the network if a `sender_key_fetcher` function is not passed in. Previously an error would be raised.
Note that fetching over the network for each payload is wasteful. Implementers should instead cache public keys when possible and pass in a function to retrieve them, as before.
### Fixed
* Converting base entity `Profile` to `DiasporaProfile` for outbound sending missed two attributes, `image_urls` and `tag_list`. Those are now included so that the values transfer into the built payload.

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,11 @@ Various utils are provided for internal and external usage.
Diaspora
........
.. autofunction:: federation.utils.diaspora.fetch_public_key
.. autofunction:: federation.utils.diaspora.get_fetch_content_endpoint
.. autofunction:: federation.utils.diaspora.get_public_endpoint
.. autofunction:: federation.utils.diaspora.parse_profile_from_hcard
.. autofunction:: federation.utils.diaspora.retrieve_and_parse_content
.. autofunction:: federation.utils.diaspora.retrieve_and_parse_profile
.. autofunction:: federation.utils.diaspora.retrieve_diaspora_hcard
.. autofunction:: federation.utils.diaspora.retrieve_diaspora_webfinger

Wyświetl plik

@ -12,6 +12,18 @@ from federation.utils.diaspora import retrieve_and_parse_profile
logger = logging.getLogger("federation")
BASE_MAPPINGS = {
Comment: "comment",
Follow: "contact",
Image: "photo",
Post: "status_message",
Profile: "profile",
Reaction: "like",
Relationship: "request",
Retraction: "retraction",
Share: "reshare",
}
MAPPINGS = {
"status_message": DiasporaPost,
"photo": Image,

Wyświetl plik

@ -1,7 +1,22 @@
# -*- coding: utf-8 -*-
import importlib
def retrieve_remote_content(entity_class, id, sender_key_fetcher=None):
"""Retrieve remote content and return an Entity object.
Currently, due to no other protocols supported, always use the Diaspora protocol.
:param entity_class: Federation entity class (from ``federation.entity.base``).
:param id: ID of the remote entity, in format``guid@domain.tld``.
:param sender_key_fetcher: Function to use to fetch sender public key. If not given, network will be used
to fetch the profile and the key. Function must take handle as only parameter and return a public key.
:returns: Entity class instance or ``None``
"""
protocol_name = "diaspora"
utils = importlib.import_module("federation.utils.%s" % protocol_name)
return utils.retrieve_and_parse_content(entity_class, id, sender_key_fetcher=sender_key_fetcher)
def retrieve_remote_profile(handle):
"""High level retrieve profile method.
@ -10,7 +25,7 @@ def retrieve_remote_profile(handle):
Currently, due to no other protocols supported, always use the Diaspora protocol.
:arg handle: The profile handle in format username@domain.tld
:param handle: The profile handle in format username@domain.tld
:returns: ``federation.entities.base.Profile`` or ``None``
"""
protocol_name = "diaspora"

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

@ -4,6 +4,7 @@ import pytest
from federation.entities.diaspora.entities import DiasporaPost
from federation.tests.fixtures.keys import get_dummy_private_key
from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD
@pytest.fixture(autouse=True)
@ -22,6 +23,11 @@ def disable_network_calls(monkeypatch):
monkeypatch.setattr("requests.get", Mock(return_value=MockResponse))
@pytest.fixture
def diaspora_public_payload():
return DIASPORA_PUBLIC_PAYLOAD
@pytest.fixture
def diasporapost():
return DiasporaPost()
@ -30,3 +36,8 @@ def diasporapost():
@pytest.fixture
def private_key():
return get_dummy_private_key()
@pytest.fixture
def public_key(private_key):
return private_key.publickey().exportKey()

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,24 @@
# -*- coding: utf-8 -*-
from unittest.mock import patch, Mock
from federation.fetchers import retrieve_remote_profile
from federation.entities.base import Post
from federation.fetchers import retrieve_remote_profile, retrieve_remote_content
class TestRetrieveRemoteProfile(object):
class TestRetrieveRemoteContent:
@patch("federation.fetchers.importlib.import_module")
def test_calls_diaspora_retrieve_and_parse_content(self, mock_import):
mock_retrieve = Mock()
mock_import.return_value = mock_retrieve
retrieve_remote_content(Post, "1234@example.com", sender_key_fetcher=sum)
mock_retrieve.retrieve_and_parse_content.assert_called_once_with(
Post, "1234@example.com", sender_key_fetcher=sum,
)
class TestRetrieveRemoteProfile:
@patch("federation.fetchers.importlib.import_module")
def test_calls_diaspora_retrieve_and_parse_profile(self, mock_import):
class MockRetrieve(Mock):
def retrieve_and_parse_profile(self, handle):
return "called with %s" % handle
mock_retrieve = MockRetrieve()
mock_retrieve = Mock()
mock_import.return_value = mock_retrieve
assert retrieve_remote_profile("foo@bar") == "called with foo@bar"
retrieve_remote_profile("foo@bar")
mock_retrieve.retrieve_and_parse_profile.assert_called_once_with("foo@bar")

Wyświetl plik

@ -1,17 +1,33 @@
# -*- coding: utf-8 -*-
import xml
from unittest.mock import patch, Mock
from urllib.parse import quote
import pytest
from lxml import html
from federation.entities.base import Profile
from federation.hostmeta.generators import DiasporaWebFinger, DiasporaHostMeta, DiasporaHCard, generate_hcard
from federation.utils.diaspora import retrieve_diaspora_hcard, retrieve_diaspora_webfinger, retrieve_diaspora_host_meta, \
_get_element_text_or_none, _get_element_attr_or_none, parse_profile_from_hcard, retrieve_and_parse_profile
from federation.entities.base import Profile, Post
from federation.hostmeta.generators import DiasporaWebFinger, DiasporaHostMeta, generate_hcard
from federation.tests.fixtures.payloads import DIASPORA_PUBLIC_PAYLOAD
from federation.utils.diaspora import (
retrieve_diaspora_hcard, retrieve_diaspora_webfinger, retrieve_diaspora_host_meta, _get_element_text_or_none,
_get_element_attr_or_none, parse_profile_from_hcard, retrieve_and_parse_profile, retrieve_and_parse_content,
get_fetch_content_endpoint, fetch_public_key)
class TestRetrieveDiasporaHCard(object):
@patch("federation.utils.diaspora.retrieve_and_parse_profile", autospec=True)
def test_fetch_public_key(mock_retrieve):
mock_retrieve.return_value = Mock(public_key="public key")
result = fetch_public_key("spam@eggs")
mock_retrieve.assert_called_once_with("spam@eggs")
assert result == "public key"
def test_get_fetch_content_endpoint():
assert get_fetch_content_endpoint("example.com", "status_message", "1234") == \
"https://example.com/fetch/status_message/1234"
class TestRetrieveDiasporaHCard:
@patch("federation.utils.diaspora.retrieve_diaspora_webfinger", return_value=None)
def test_retrieve_webfinger_is_called(self, mock_retrieve):
retrieve_diaspora_hcard("bob@localhost")
@ -40,7 +56,7 @@ class TestRetrieveDiasporaHCard(object):
assert document == None
class TestRetrieveDiasporaWebfinger(object):
class TestRetrieveDiasporaWebfinger:
@patch("federation.utils.diaspora.retrieve_diaspora_host_meta", return_value=None)
def test_retrieve_host_meta_is_called(self, mock_retrieve):
retrieve_diaspora_webfinger("bob@localhost")
@ -82,7 +98,7 @@ class TestRetrieveDiasporaWebfinger(object):
assert document == None
class TestRetrieveDiasporaHostMeta(object):
class TestRetrieveDiasporaHostMeta:
@patch("federation.utils.diaspora.XRD.parse_xrd")
@patch("federation.utils.diaspora.fetch_document")
def test_fetch_document_is_called(self, mock_fetch, mock_xrd):
@ -100,7 +116,48 @@ class TestRetrieveDiasporaHostMeta(object):
assert document == None
class TestGetElementTextOrNone(object):
class TestRetrieveAndParseContent:
@patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None))
@patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs")
def test_calls_fetch_document(self, mock_get, mock_fetch):
retrieve_and_parse_content(Post, "1234@example.com")
mock_fetch.assert_called_once_with("https://example.com/fetch/spam/eggs")
@patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None))
@patch("federation.utils.diaspora.get_fetch_content_endpoint")
def test_calls_get_fetch_content_endpoint(self, mock_get, mock_fetch):
retrieve_and_parse_content(Post, "1234@example.com")
mock_get.assert_called_once_with("example.com", "status_message", "1234")
mock_get.reset_mock()
retrieve_and_parse_content(Post, "fooobar@1234@example.com")
mock_get.assert_called_once_with("example.com", "status_message", "fooobar@1234")
@patch("federation.utils.diaspora.fetch_document", return_value=(DIASPORA_PUBLIC_PAYLOAD, 200, None))
@patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs")
@patch("federation.utils.diaspora.handle_receive", return_value=("sender", "protocol", ["entity"]))
def test_calls_handle_receive(self, mock_handle, mock_get, mock_fetch):
entity = retrieve_and_parse_content(Post, "1234@example.com", sender_key_fetcher=sum)
mock_handle.assert_called_once_with(DIASPORA_PUBLIC_PAYLOAD, sender_key_fetcher=sum)
assert entity == "entity"
@patch("federation.utils.diaspora.fetch_document", return_value=(None, None, Exception()))
@patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs")
def test_raises_on_fetch_error(self, mock_get, mock_fetch):
with pytest.raises(Exception):
retrieve_and_parse_content(Post, "1234@example.com")
def test_raises_on_unknown_entity(self):
with pytest.raises(ValueError):
retrieve_and_parse_content(dict, "1234@example.com")
@patch("federation.utils.diaspora.fetch_document", return_value=(None, 404, None))
@patch("federation.utils.diaspora.get_fetch_content_endpoint", return_value="https://example.com/fetch/spam/eggs")
def test_returns_on_404(self, mock_get, mock_fetch):
result = retrieve_and_parse_content(Post, "1234@example.com")
assert not result
class TestGetElementTextOrNone:
doc = html.fromstring("<foo>bar</foo>")
def test_text_returned_on_element(self):
@ -110,7 +167,7 @@ class TestGetElementTextOrNone(object):
assert _get_element_text_or_none(self.doc, "bar") == None
class TestGetElementAttrOrNone(object):
class TestGetElementAttrOrNone:
doc = html.fromstring("<foo src='baz'>bar</foo>")
def test_attr_returned_on_attr(self):
@ -123,7 +180,7 @@ class TestGetElementAttrOrNone(object):
assert _get_element_attr_or_none(self.doc, "bar", "href") == None
class TestParseProfileFromHCard(object):
class TestParseProfileFromHCard:
def test_profile_is_parsed(self):
hcard = generate_hcard(
"diaspora",
@ -151,7 +208,7 @@ class TestParseProfileFromHCard(object):
profile.validate()
class TestRetrieveAndParseProfile(object):
class TestRetrieveAndParseProfile:
@patch("federation.utils.diaspora.retrieve_diaspora_hcard", return_value=None)
def test_retrieve_diaspora_hcard_is_called(self, mock_retrieve):
retrieve_and_parse_profile("foo@bar")

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.
@ -122,6 +132,43 @@ def parse_profile_from_hcard(hcard, handle):
return profile
def retrieve_and_parse_content(entity_class, id, sender_key_fetcher=None):
"""Retrieve remote content and return an Entity class instance.
This is basically the inverse of receiving an entity. Instead, we fetch it, then call 'handle_receive'.
:param entity_class: Federation entity class (from ``federation.entity.base``).
:param id: GUID and domain of the remote entity, in format``guid@domain.tld``.
:param sender_key_fetcher: Function to use to fetch sender public key. If not given, network will be used
to fetch the profile and the key. Function must take handle as only parameter and return a public key.
:returns: Entity object instance or ``None``
:raises: ``ValueError`` if ``entity_class`` is not valid.
"""
from federation.entities.diaspora.mappers import BASE_MAPPINGS
entity_type = BASE_MAPPINGS.get(entity_class)
if not entity_type:
raise ValueError("Unknown entity_class %s" % entity_class)
guid, domain = id.rsplit("@", 1)
url = get_fetch_content_endpoint(domain, entity_type, guid)
document, status_code, error = fetch_document(url)
if status_code == 200:
_sender, _protocol, entities = handle_receive(document, sender_key_fetcher=sender_key_fetcher)
if len(entities) > 1:
logger.warning("retrieve_and_parse_content - more than one entity parsed from remote even though we"
"expected only one! ID %s", id)
if entities:
return entities[0]
return
elif status_code == 404:
logger.warning("retrieve_and_parse_content - remote content %s not found", id)
return
if error:
raise error
raise Exception("retrieve_and_parse_content - unknown problem when fetching document: %s, %s, %s" % (
document, status_code, error,
))
def retrieve_and_parse_profile(handle):
"""
Retrieve the remote user and return a Profile object.
@ -142,5 +189,14 @@ def retrieve_and_parse_profile(handle):
return profile
def get_fetch_content_endpoint(domain, entity_type, guid):
"""Get remote fetch content endpoint.
See: https://diaspora.github.io/diaspora_federation/federation/fetching.html
"""
return "https://%s/fetch/%s/%s" % (domain, entity_type, guid)
def get_public_endpoint(domain):
"""Get remote endpoint for delivering public payloads."""
return "https://%s/receive/public" % domain