Commit 7cf09762 authored by Eliot Berriot's avatar Eliot Berriot 💬

Merge branch 'federation-actor-public-key' into 'develop'

Federation actor public key

See merge request funkwhale/funkwhale!115
parents 471d1fe9 f9481a5b
Pipeline #631 passed with stages
in 3 minutes and 52 seconds
......@@ -86,3 +86,4 @@ front/selenium-debug.log
docs/_build
data/
.env
......@@ -3,6 +3,53 @@ Changelog
.. towncrier
Release notes:
Preparing for federation
^^^^^^^^^^^^^^^^^^^^^^^^
In order to prepare for federation (see #136 and #137), new API endpoints
have been added under /federation and /.well-known/webfinger.
For these endpoints to work, you will need to update your nginx configuration,
and add the following snippets::
location /federation/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/federation/;
}
location /.well-known/webfinger {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-api/.well-known/webfinger;
}
This will ensure federation endpoints will be reachable in the future.
You can of course skip this part if you know you will not federate your instance.
A new ``FEDERATION_ENABLED`` env var have also been added to control wether
federation is enabled or not on the application side. This settings defaults
to True, which should have no consequencies at the moment, since actual
federation is not implemented and the only available endpoints are for
testing purposes.
Add ``FEDERATION_ENABLED=false`` to your .env file to disable federation
on the application side.
The last step involves generating RSA private and public keys for signing
your instance requests on the federation. This can be done via::
# on docker setups
docker-compose --rm api python manage.py generate_keys --no-input
# on non-docker setups
source /srv/funkwhale/virtualenv/bin/activate
source /srv/funkwhale/load_env
python manage.py generate_keys --no-input
That's it :)
0.7 (2018-03-21)
----------------
......
......@@ -73,6 +73,19 @@ via the following command::
docker-compose -f dev.yml build
Creating your env file
^^^^^^^^^^^^^^^^^^^^^^
We provide a working .env.dev configuration file that is suitable for
development. However, to enable customization on your machine, you should
also create a .env file that will hold your personal environment
variables (those will not be commited to the project).
Create it like this::
touch .env
Database management
^^^^^^^^^^^^^^^^^^^
......
......@@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/dev/ref/settings/
"""
from __future__ import absolute_import, unicode_literals
from urllib.parse import urlsplit
import os
import environ
from funkwhale_api import __version__
......@@ -24,8 +25,13 @@ try:
except FileNotFoundError:
pass
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
FUNKWHALE_URL = env('FUNKWHALE_URL')
FUNKWHALE_HOSTNAME = urlsplit(FUNKWHALE_URL).netloc
FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
# APP CONFIGURATION
# ------------------------------------------------------------------------------
......@@ -395,4 +401,5 @@ ACCOUNT_USERNAME_BLACKLIST = [
'owner',
'superuser',
'staff',
'service',
] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[])
......@@ -13,6 +13,9 @@ urlpatterns = [
url(settings.ADMIN_URL, admin.site.urls),
url(r'^api/', include(("config.api_urls", 'api'), namespace="api")),
url(r'^', include(
('funkwhale_api.federation.urls', 'federation'),
namespace="federation")),
url(r'^api/v1/auth/', include('rest_auth.urls')),
url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
url(r'^accounts/', include('allauth.urls')),
......
from django.forms import widgets
from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry
federation = types.Section('federation')
@global_preferences_registry.register
class FederationPrivateKey(types.StringPreference):
show_in_api = False
section = federation
name = 'private_key'
default = ''
help_text = (
'Instance private key, used for signing federation HTTP requests'
)
verbose_name = (
'Instance private key (keep it secret, do not change it)'
)
@global_preferences_registry.register
class FederationPublicKey(types.StringPreference):
show_in_api = False
section = federation
name = 'public_key'
default = ''
help_text = (
'Instance public key, used for signing federation HTTP requests'
)
verbose_name = (
'Instance public key (do not change it)'
)
class MalformedPayload(ValueError):
pass
......@@ -4,16 +4,16 @@ import requests_http_signature
from funkwhale_api.factories import registry
from . import signing
from . import keys
registry.register(signing.get_key_pair, name='federation.KeyPair')
registry.register(keys.get_key_pair, name='federation.KeyPair')
@registry.register(name='federation.SignatureAuth')
class SignatureAuthFactory(factory.Factory):
algorithm = 'rsa-sha256'
key = factory.LazyFunction(lambda: signing.get_key_pair()[0])
key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
key_id = factory.Faker('url')
class Meta:
......
from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend as crypto_default_backend
import requests
from . import exceptions
def get_key_pair(size=2048):
key = rsa.generate_private_key(
backend=crypto_default_backend(),
public_exponent=65537,
key_size=size
)
private_key = key.private_bytes(
crypto_serialization.Encoding.PEM,
crypto_serialization.PrivateFormat.PKCS8,
crypto_serialization.NoEncryption())
public_key = key.public_key().public_bytes(
crypto_serialization.Encoding.PEM,
crypto_serialization.PublicFormat.PKCS1
)
return private_key, public_key
def get_public_key(actor_url):
"""
Given an actor_url, request it and extract publicKey data from
the response payload.
"""
response = requests.get(actor_url)
response.raise_for_status()
payload = response.json()
try:
return {
'public_key_pem': payload['publicKey']['publicKeyPem'],
'id': payload['publicKey']['id'],
'owner': payload['publicKey']['owner'],
}
except KeyError:
raise exceptions.MalformedPayload(str(payload))
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.federation import keys
class Command(BaseCommand):
help = (
'Generate a public/private key pair for your instance,'
' for federation purposes. If a key pair already exists, does nothing.'
)
def add_arguments(self, parser):
parser.add_argument(
'--replace',
action='store_true',
dest='replace',
default=False,
help='Replace existing key pair, if any',
)
parser.add_argument(
'--noinput', '--no-input', action='store_false', dest='interactive',
help="Do NOT prompt the user for input of any kind.",
)
@transaction.atomic
def handle(self, *args, **options):
preferences = global_preferences_registry.manager()
existing_public = preferences['federation__public_key']
existing_private = preferences['federation__public_key']
if existing_public or existing_private and not options['replace']:
raise CommandError(
'Keys are already present! '
'Replace them with --replace if you know what you are doing.')
if options['interactive']:
message = (
'Are you sure you want to do this?\n\n'
"Type 'yes' to continue, or 'no' to cancel: "
)
if input(''.join(message)) != 'yes':
raise CommandError("Operation cancelled.")
private, public = keys.get_key_pair()
preferences['federation__public_key'] = public.decode('utf-8')
preferences['federation__private_key'] = private.decode('utf-8')
self.stdout.write(
'Your new key pair was generated.'
'Your public key is now:\n\n{}'.format(public.decode('utf-8'))
)
from rest_framework.renderers import JSONRenderer
class ActivityPubRenderer(JSONRenderer):
media_type = 'application/activity+json'
class WebfingerRenderer(JSONRenderer):
media_type = 'application/jrd+json'
from django.urls import reverse
from django.conf import settings
from . import utils
def repr_instance_actor():
"""
We do not use a serializer here, since it's pretty static
"""
return {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{},
],
'id': utils.full_url(reverse('federation:instance-actor')),
'type': 'Service',
'inbox': utils.full_url(reverse('federation:instance-inbox')),
'outbox': utils.full_url(reverse('federation:instance-outbox')),
}
import requests
import requests_http_signature
from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend as crypto_default_backend
def get_key_pair(size=2048):
key = rsa.generate_private_key(
backend=crypto_default_backend(),
public_exponent=65537,
key_size=size
)
private_key = key.private_bytes(
crypto_serialization.Encoding.PEM,
crypto_serialization.PrivateFormat.PKCS8,
crypto_serialization.NoEncryption())
public_key = key.public_key().public_bytes(
crypto_serialization.Encoding.PEM,
crypto_serialization.PublicFormat.PKCS1
)
return private_key, public_key
def verify(request, public_key):
return requests_http_signature.HTTPSignatureAuth.verify(
......
from rest_framework import routers
from . import views
router = routers.SimpleRouter(trailing_slash=False)
router.register(
r'instance',
views.InstanceViewSet,
'instance')
router.register(
r'.well-known',
views.WellKnownViewSet,
'well-known')
urlpatterns = router.urls
from django.conf import settings
def full_url(path):
"""
Given a relative path, return a full url usable for federation purpose
"""
root = settings.FUNKWHALE_URL
if path.startswith('/') and root.endswith('/'):
return root + path[1:]
elif not path.startswith('/') and not root.endswith('/'):
return root + '/' + path
else:
return root + path
from django import forms
from django.conf import settings
from django.http import HttpResponse
from rest_framework import viewsets
from rest_framework import views
from rest_framework import response
from rest_framework.decorators import list_route
from . import renderers
from . import serializers
from . import webfinger
class FederationMixin(object):
def dispatch(self, request, *args, **kwargs):
if not settings.FEDERATION_ENABLED:
return HttpResponse(status=405)
return super().dispatch(request, *args, **kwargs)
class InstanceViewSet(FederationMixin, viewsets.GenericViewSet):
authentication_classes = []
permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer]
@list_route(methods=['get'])
def actor(self, request, *args, **kwargs):
return response.Response(serializers.repr_instance_actor())
@list_route(methods=['get'])
def inbox(self, request, *args, **kwargs):
raise NotImplementedError()
@list_route(methods=['get'])
def outbox(self, request, *args, **kwargs):
raise NotImplementedError()
class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
authentication_classes = []
permission_classes = []
renderer_classes = [renderers.WebfingerRenderer]
@list_route(methods=['get'])
def webfinger(self, request, *args, **kwargs):
try:
resource_type, resource = webfinger.clean_resource(
request.GET['resource'])
cleaner = getattr(webfinger, 'clean_{}'.format(resource_type))
result = cleaner(resource)
except forms.ValidationError as e:
return response.Response({
'errors': {
'resource': e.message
}
}, status=400)
except KeyError:
return response.Response({
'errors': {
'resource': 'This field is required',
}
}, status=400)
handler = getattr(self, 'handler_{}'.format(resource_type))
data = handler(result)
return response.Response(data)
def handler_acct(self, clean_result):
username, hostname = clean_result
if username == 'service':
return webfinger.serialize_system_acct()
return {}
from django import forms
from django.conf import settings
from django.urls import reverse
from . import utils
VALID_RESOURCE_TYPES = ['acct']
def clean_resource(resource_string):
if not resource_string:
raise forms.ValidationError('Invalid resource string')
try:
resource_type, resource = resource_string.split(':', 1)
except ValueError:
raise forms.ValidationError('Missing webfinger resource type')
if resource_type not in VALID_RESOURCE_TYPES:
raise forms.ValidationError('Invalid webfinger resource type')
return resource_type, resource
def clean_acct(acct_string):
try:
username, hostname = acct_string.split('@')
except ValueError:
raise forms.ValidationError('Invalid format')
if hostname != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError('Invalid hostname')
if username != 'service':
raise forms.ValidationError('Invalid username')
return username, hostname
def serialize_system_acct():
return {
'subject': 'acct:service@{}'.format(settings.FEDERATION_HOSTNAME),
'aliases': [
utils.full_url(reverse('federation:instance-actor'))
],
'links': [
{
'rel': 'self',
'type': 'application/activity+json',
'href': utils.full_url(reverse('federation:instance-actor')),
}
]
}
......@@ -10,3 +10,4 @@ pytest-mock
pytest-sugar
pytest-xdist
pytest-cov
requests-mock
......@@ -2,6 +2,7 @@ import factory
import tempfile
import shutil
import pytest
import requests_mock
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache
......@@ -148,3 +149,9 @@ def media_root(settings):
settings.MEDIA_ROOT = tmp_dir
yield settings.MEDIA_ROOT
shutil.rmtree(tmp_dir)
@pytest.fixture
def r_mock():
with requests_mock.mock() as m:
yield m
from django.core.management import call_command
def test_generate_instance_key_pair(preferences, mocker):
mocker.patch(
'funkwhale_api.federation.keys.get_key_pair',
return_value=(b'private', b'public'))
assert preferences['federation__public_key'] == ''
assert preferences['federation__private_key'] == ''
call_command('generate_keys', interactive=False)
assert preferences['federation__private_key'] == 'private'
assert preferences['federation__public_key'] == 'public'
from funkwhale_api.federation import keys
def test_public_key_fetching(r_mock):
payload = {
'id': 'https://actor.mock/users/actor#main-key',
'owner': 'test',
'publicKeyPem': 'test_pem',
}
actor = 'https://actor.mock/'
r_mock.get(actor, json={'publicKey': payload})
r = keys.get_public_key(actor)
assert r['id'] == payload['id']
assert r['owner'] == payload['owner']
assert r['public_key_pem'] == payload['publicKeyPem']
......@@ -4,6 +4,7 @@ import pytest
import requests_http_signature
from funkwhale_api.federation import signing
from funkwhale_api.federation import keys
def test_can_sign_and_verify_request(factories):
......@@ -45,7 +46,7 @@ def test_verify_fails_with_wrong_key(factories):
def test_can_verify_django_request(factories, api_request):
private_key, public_key = signing.get_key_pair()
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key
)
......@@ -61,7 +62,7 @@ def test_can_verify_django_request(factories, api_request):
def test_can_verify_django_request_digest(factories, api_request):
private_key, public_key = signing.get_key_pair()
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key,
method='post',
......@@ -81,7 +82,7 @@ def test_can_verify_django_request_digest(factories, api_request):
def test_can_verify_django_request_digest_failure(factories, api_request):
private_key, public_key = signing.get_key_pair()
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key,
method='post',
......@@ -102,7 +103,7 @@ def test_can_verify_django_request_digest_failure(factories, api_request):
def test_can_verify_django_request_failure(factories, api_request):
private_key, public_key = signing.get_key_pair()
private_key, public_key = keys.get_key_pair()
signed_request = factories['federation.SignedRequest'](
auth__key=private_key
)
......
import pytest
from funkwhale_api.federation import utils
@pytest.mark.parametrize('url,path,expected', [
('http://test.com', '/hello', 'http://test.com/hello'),
('http://test.com/', 'hello', 'http://test.com/hello'),
('http://test.com/', '/hello', 'http://test.com/hello'),
('http://test.com', 'hello', 'http://test.com/hello'),
])
def test_full_url(settings, url, path, expected):
settings.FUNKWHALE_URL = url
assert utils.full_url(path) == expected
from django.urls import reverse
import pytest
from funkwhale_api.federation import webfinger
def test_instance_actor(db, settings, api_client):
settings.FUNKWHALE_URL = 'http://test.com'
url = reverse('federation:instance-actor')
response = api_client.get(url)
assert response.data['id'] == (
settings.FUNKWHALE_URL + url
)
assert response.data['type'] == 'Service'
assert response.data['inbox'] == (
settings.FUNKWHALE_URL + reverse('federation:instance-inbox')
)
assert response.data['outbox'] == (
settings.FUNKWHALE_URL + reverse('federation:instance-outbox')
)
assert response.data['@context'] == [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{},
]
@pytest.mark.parametrize('route', [
'instance-outbox',
'instance-inbox',
'instance-actor',
'well-known-webfinger',
])
def test_instance_inbox_405_if_federation_disabled(
db, settings, api_client, route):
settings.FEDERATION_ENABLED = False
url = reverse('federation:{}'.format(route))
response = api_client.get(url)
assert response.status_code == 405
def test_wellknown_webfinger_validates_resource(
db, api_client, settings, mocker):
clean = mocker.spy(webfinger, 'clean_resource')
settings.FEDERATION_ENABLED = True
url = reverse('federation:well-known-webfinger')
response = api_client.get(url, data={'resource': 'something'})
clean.assert_called_once_with('something')
assert url == '/.well-known/webfinger'
assert response.status_code == 400
assert response.data['errors']['resource'] == (
'Missing webfinger resource type'
)
def test_wellknown_webfinger_system(
db, api_client, settings, mocker):
settings.FEDERATION_ENABLED = True
settings.FEDERATION_HOSTNAME = 'test.federation'
url = reverse('federation:well-known-webfinger')
response = api_client.get(
url, data={'resource': 'acct:service@test.federation'})
assert response.status_code == 200
assert response['Content-Type'] == 'application/jrd+json'
assert response.data == webfinger.serialize_system_acct()
import pytest
from django import forms
from django.urls import reverse
from funkwhale_api.federation import webfinger
def test_webfinger_clean_resource():
t, r = webfinger.clean_resource('acct:service@test.federation')
assert t == 'acct'
assert r == 'service@test.federation'
@pytest.mark.parametrize('resource,message', [
('', 'Invalid resource string'),
('service@test.com', 'Missing webfinger resource type'),
('noop:service@test.com', 'Invalid webfinger resource type'),
])
def test_webfinger_clean_resource_errors(resource, message):
with pytest.raises(forms.ValidationError) as excinfo:
webfinger.clean_resource(resource)
assert message == str(excinfo)