From 5ddce16213a8e7b4e9d052a14ed8d7e37ac5f068 Mon Sep 17 00:00:00 2001 From: Andrew Godwin Date: Sun, 20 Nov 2022 18:29:19 -0700 Subject: Add a system actor to sign outgoing S2S GETs --- core/ld.py | 6 +++ core/models/config.py | 3 ++ core/signatures.py | 56 ++++++++++++++++---- takahe/urls.py | 3 +- users/apps.py | 6 +++ users/models/__init__.py | 1 + users/models/identity.py | 102 +++++++++++++++--------------------- users/models/system_actor.py | 68 ++++++++++++++++++++++++ users/tests/conftest.py | 51 ++++++++++++++++++ users/tests/models/test_identity.py | 2 +- users/views/activitypub.py | 84 +++++++++++++++++++++-------- users/views/identity.py | 4 ++ 12 files changed, 293 insertions(+), 93 deletions(-) create mode 100644 users/models/system_actor.py create mode 100644 users/tests/conftest.py diff --git a/core/ld.py b/core/ld.py index fff0526..1d79abf 100644 --- a/core/ld.py +++ b/core/ld.py @@ -358,6 +358,12 @@ schemas = { ] }, }, + "joinmastodon.org/ns": { + "contentType": "application/ld+json", + "documentUrl": "http://joinmastodon.org/ns", + "contextUrl": None, + "document": {}, + }, } DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" diff --git a/core/models/config.py b/core/models/config.py index ffe7172..d69205c 100644 --- a/core/models/config.py +++ b/core/models/config.py @@ -154,6 +154,9 @@ class Config(models.Model): version: str = __version__ + system_actor_public_key: str = "" + system_actor_private_key: str = "" + site_name: str = "Takahē" highlight_color: str = "#449c8c" site_about: str = "

Welcome!

\n\nThis is a community running Takahē." diff --git a/core/signatures.py b/core/signatures.py index d981f87..df3ca61 100644 --- a/core/signatures.py +++ b/core/signatures.py @@ -1,10 +1,11 @@ import base64 import json -from typing import Dict, List, Literal, TypedDict +from typing import Dict, List, Literal, Optional, Tuple, TypedDict from urllib.parse import urlparse import httpx -from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa from django.http import HttpRequest from django.utils import timezone from django.utils.http import http_date, parse_http_date @@ -30,6 +31,32 @@ class VerificationFormatError(VerificationError): pass +class RsaKeys: + @classmethod + def generate_keypair(cls) -> Tuple[str, str]: + """ + Generates a new RSA keypair + """ + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + private_key_serialized = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + public_key_serialized = ( + private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode("ascii") + ) + return private_key_serialized, public_key_serialized + + class HttpSignature: """ Allows for calculation and verification of HTTP signatures @@ -138,28 +165,37 @@ class HttpSignature: @classmethod async def signed_request( - self, + cls, uri: str, - body: Dict, + body: Optional[Dict], private_key: str, key_id: str, content_type: str = "application/json", - method: Literal["post"] = "post", + method: Literal["get", "post"] = "post", ): """ Performs an async request to the given path, with a document, signed as an identity. """ + # Create the core header field set uri_parts = urlparse(uri) date_string = http_date() - body_bytes = json.dumps(body).encode("utf8") headers = { "(request-target)": f"{method} {uri_parts.path}", "Host": uri_parts.hostname, "Date": date_string, - "Digest": self.calculate_digest(body_bytes), - "Content-Type": content_type, } + # If we have a body, add a digest and content type + if body is not None: + body_bytes = json.dumps(body).encode("utf8") + headers["Digest"] = cls.calculate_digest(body_bytes) + headers["Content-Type"] = content_type + else: + body_bytes = b"" + # GET requests get implicit accept headers added + if method == "get": + headers["Accept"] = "application/activity+json, application/ld+json" + # Sign the headers signed_string = "\n".join( f"{name.lower()}: {value}" for name, value in headers.items() ) @@ -172,7 +208,7 @@ class HttpSignature: signed_string.encode("ascii"), "sha256", ) - headers["Signature"] = self.compile_signature( + headers["Signature"] = cls.compile_signature( { "keyid": key_id, "headers": list(headers.keys()), @@ -180,6 +216,7 @@ class HttpSignature: "algorithm": "rsa-sha256", } ) + # Send the request with all those headers except the pseudo one del headers["(request-target)"] async with httpx.AsyncClient() as client: response = await client.request( @@ -187,6 +224,7 @@ class HttpSignature: uri, headers=headers, content=body_bytes, + follow_redirects=method == "get", ) if response.status_code >= 400: raise ValueError( diff --git a/takahe/urls.py b/takahe/urls.py index abb8b2c..614ec3b 100644 --- a/takahe/urls.py +++ b/takahe/urls.py @@ -104,11 +104,12 @@ urlpatterns = [ path("@/activate/", identity.ActivateIdentity.as_view()), path("identity/select/", identity.SelectIdentity.as_view()), path("identity/create/", identity.CreateIdentity.as_view()), - # Well-known endpoints + # Well-known endpoints and system actor path(".well-known/webfinger", activitypub.Webfinger.as_view()), path(".well-known/host-meta", activitypub.HostMeta.as_view()), path(".well-known/nodeinfo", activitypub.NodeInfo.as_view()), path("nodeinfo/2.0/", activitypub.NodeInfo2.as_view()), + path("actor/", activitypub.SystemActorView.as_view()), # Django admin path("djadmin/", djadmin.site.urls), # Media files diff --git a/users/apps.py b/users/apps.py index 88f7b17..05b5c3f 100644 --- a/users/apps.py +++ b/users/apps.py @@ -4,3 +4,9 @@ from django.apps import AppConfig class UsersConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "users" + + def ready(self) -> None: + # Generate the server actor keypair if needed + from users.models import SystemActor + + SystemActor.generate_keys_if_needed() diff --git a/users/models/__init__.py b/users/models/__init__.py index fc0d402..1c5f519 100644 --- a/users/models/__init__.py +++ b/users/models/__init__.py @@ -5,5 +5,6 @@ from .identity import Identity, IdentityStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa from .invite import Invite # noqa from .password_reset import PasswordReset # noqa +from .system_actor import SystemActor # noqa from .user import User # noqa from .user_event import UserEvent # noqa diff --git a/users/models/identity.py b/users/models/identity.py index 2190c9c..98e7df9 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -5,8 +5,6 @@ from urllib.parse import urlparse import httpx import urlman from asgiref.sync import async_to_sync, sync_to_async -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa from django.db import models from django.template.defaultfilters import linebreaks_filter from django.templatetags.static import static @@ -15,9 +13,11 @@ from django.utils import timezone from core.exceptions import ActorMismatchError from core.html import sanitize_post from core.ld import canonicalise, media_type_from_filename +from core.signatures import RsaKeys from core.uploads import upload_namer from stator.models import State, StateField, StateGraph, StatorModel from users.models.domain import Domain +from users.models.system_actor import SystemActor class IdentityStates(StateGraph): @@ -301,15 +301,16 @@ class Identity(StatorModel): """ domain = handle.split("@")[1] try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"https://{domain}/.well-known/webfinger?resource=acct:{handle}", - headers={"Accept": "application/json"}, - follow_redirects=True, - ) + response = await SystemActor().signed_request( + method="get", + uri=f"https://{domain}/.well-known/webfinger?resource=acct:{handle}", + ) except httpx.RequestError: return None, None - if response.status_code >= 400: + if response.status_code == 404: + # We don't trust this as much as 410 Gone, but skip for now + return None, None + if response.status_code >= 500: return None, None data = response.json() if data["subject"].startswith("acct:"): @@ -329,40 +330,39 @@ class Identity(StatorModel): """ if self.local: raise ValueError("Cannot fetch local identities") - async with httpx.AsyncClient() as client: - try: - response = await client.get( - self.actor_uri, - headers={"Accept": "application/json"}, - follow_redirects=True, - ) - except httpx.RequestError: - return False - if response.status_code == 410: - # Their account got deleted, so let's do the same. - if self.pk: - await Identity.objects.filter(pk=self.pk).adelete() - return False - if response.status_code >= 400: - return False - document = canonicalise(response.json(), include_security=True) - self.name = document.get("name") - self.profile_uri = document.get("url") - self.inbox_uri = document.get("inbox") - self.outbox_uri = document.get("outbox") - self.summary = document.get("summary") - self.username = document.get("preferredUsername") - if self.username and "@value" in self.username: - self.username = self.username["@value"] - if self.username: - self.username = self.username.lower() - self.manually_approves_followers = document.get( - "as:manuallyApprovesFollowers" + try: + response = await SystemActor().signed_request( + method="get", + uri=self.actor_uri, ) - self.public_key = document.get("publicKey", {}).get("publicKeyPem") - self.public_key_id = document.get("publicKey", {}).get("id") - self.icon_uri = document.get("icon", {}).get("url") - self.image_uri = document.get("image", {}).get("url") + except httpx.RequestError: + return False + if response.status_code == 410: + # Their account got deleted, so let's do the same. + if self.pk: + await Identity.objects.filter(pk=self.pk).adelete() + return False + if response.status_code == 404: + # We don't trust this as much as 410 Gone, but skip for now + return False + if response.status_code >= 500: + return False + document = canonicalise(response.json(), include_security=True) + self.name = document.get("name") + self.profile_uri = document.get("url") + self.inbox_uri = document.get("inbox") + self.outbox_uri = document.get("outbox") + self.summary = document.get("summary") + self.username = document.get("preferredUsername") + if self.username and "@value" in self.username: + self.username = self.username["@value"] + if self.username: + self.username = self.username.lower() + self.manually_approves_followers = document.get("as:manuallyApprovesFollowers") + self.public_key = document.get("publicKey", {}).get("publicKeyPem") + self.public_key_id = document.get("publicKey", {}).get("id") + self.icon_uri = document.get("icon", {}).get("url") + self.image_uri = document.get("image", {}).get("url") # Now go do webfinger with that info to see if we can get a canonical domain actor_url_parts = urlparse(self.actor_uri) get_domain = sync_to_async(Domain.get_remote_domain) @@ -387,22 +387,6 @@ class Identity(StatorModel): def generate_keypair(self): if not self.local: raise ValueError("Cannot generate keypair for remote user") - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - ) - self.private_key = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ).decode("ascii") - self.public_key = ( - private_key.public_key() - .public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - .decode("ascii") - ) + self.private_key, self.public_key = RsaKeys.generate_keypair() self.public_key_id = self.actor_uri + "#main-key" self.save() diff --git a/users/models/system_actor.py b/users/models/system_actor.py new file mode 100644 index 0000000..28ef1a8 --- /dev/null +++ b/users/models/system_actor.py @@ -0,0 +1,68 @@ +from typing import Dict, Literal, Optional + +from django.conf import settings + +from core.models import Config +from core.signatures import HttpSignature, RsaKeys + + +class SystemActor: + """ + Represents the system actor, that we use to sign all HTTP requests + that are not on behalf of an Identity. + + Note that this needs Config.system to be set to be initialised. + """ + + def __init__(self): + self.private_key = Config.system.system_actor_private_key + self.public_key = Config.system.system_actor_public_key + self.actor_uri = f"https://{settings.MAIN_DOMAIN}/actor/" + self.public_key_id = self.actor_uri + "#main-key" + self.profile_uri = f"https://{settings.MAIN_DOMAIN}/about/" + self.username = "__system__" + + def generate_keys(self): + self.private_key, self.public_key = RsaKeys.generate_keypair() + Config.set_system("system_actor_private_key", self.private_key) + Config.set_system("system_actor_public_key", self.public_key) + + @classmethod + def generate_keys_if_needed(cls): + # Load the system config into the right place + Config.system = Config.load_system() + instance = cls() + if "-----BEGIN" not in instance.private_key: + instance.generate_keys() + + def to_ap(self): + return { + "id": self.actor_uri, + "type": "Application", + "inbox": self.actor_uri + "/inbox/", + "preferredUsername": self.username, + "url": self.profile_uri, + "as:manuallyApprovesFollowers": True, + "publicKey": { + "id": self.public_key_id, + "owner": self.actor_uri, + "publicKeyPem": self.public_key, + }, + } + + async def signed_request( + self, + method: Literal["get", "post"], + uri: str, + body: Optional[Dict] = None, + ): + """ + Performs a signed request on behalf of the System Actor. + """ + return await HttpSignature.signed_request( + method=method, + uri=uri, + body=body, + private_key=self.private_key, + key_id=self.public_key_id, + ) diff --git a/users/tests/conftest.py b/users/tests/conftest.py new file mode 100644 index 0000000..e1eeb4b --- /dev/null +++ b/users/tests/conftest.py @@ -0,0 +1,51 @@ +import pytest + +from core.models import Config + +# Our testing-only keypair +private_key = """-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCzNJa9JIxQpOtQ +z8UQKXDPREF9DyBliGu3uPWo6DMnkOm7hoh2+nOryrWDqWOFaVK//n7kltHXUEbm +U3exh0/0iWfzx2AbNrI04csAvW/hRvHbHBnVTotSxzqTd3ESkpcSW4xVuz9aCcFR +kW3unSCO3fF0Lh8Jsy9N/CT6oTnwG+ZpeGvHVbh9xfR5Ww6zA7z8A6B17hbzdMd/ +3qUPijyIb5se4cWVtGg/ZJ0X1syn9u9kpwUjhHlyWH/esMRHxPuW49BPZPhhKs1+ +t//4xgZcRX515qFqPS2EtYgZAfh7M3TRv8uCSzL4TT+8ka9IUwKdV6TFaqH27bAG +KyJQfGaTAgMBAAECggEALZY5qFjlRtiFMfQApdlc5KTw4d7Yt2tqN3zaJUMYTD7d +boJNMbMJfNCetyT+d6Aw2D1ly0GglNzLhGkEQElzKfpQUt/Lj3CtCa3Mpd4K2Wxi +NwJhgfUulPqwaHYQchCPVLCsNNziw0VLA7Rymionb6B+/TaEV8PYy0ZSo90ir3UD +CL5t+IWgIPiy6pk1wGOmeB+tU4+V7/hFel+vPFNahafqVhLE311dfx2aOfweAEfN +e4JoPeJP1/fB+BVZMyVSAraKz6wheymBBNKKn/vpFsdd6it2AP4UZeFp6ma9wT9t +nk65IpHg1MBxazQd7621GrPH+ZnhMg62H/FEj6rIDQKBgQC1w1fEbk+zjI54DXU8 +FAe5cJbZS89fMP5CtzlWKzTzfdaavT+5cUYp3XAv37tSGsqYAXxY+4bHGa+qdCQO +I41cmylWGNX2e29/p2BspDPM6YQ0Z21MxFRBTWvHFrhd0bF1cXKBKPttdkKvzOEP +6uNy+/QtRNn9xF/ZjaMHcyPPTQKBgQD8ZdOmZ3TMsYJchAjjseN8S+Objw2oZzmK +6I1ULJBz3DWiyCUfir+pMjSH4fsAf9zrHkiM7xUgMByTukVRt16BrT7TlEBanAxc +/AKdNB3f0pza829LCz1lMAUn+ngZLTmRR+1rQFXqTjhB+0peJzKiMli+9BBhL9Ry +jMeTuLHdXwKBgGiz9kL5KIBNX2RYnEfXYfu4l6zktrgnCNB1q1mv2fjJbG4GxkaU +sc47+Pwa7VUGid22PWMkwSa/7SlLbdmXMT8/QjiOZfJueHQYfrsWe6B2g+mMCrJG +BiL37jXpKJsiyA7XIxaz/OG5VgDfDGaW8B60dJv/JXPBQ1WW+Wq5MM+hAoGAAUdS +xykHAnJzwpw4n06rZFnOEV+sJgo/1GBRNvfy02NuMiDpbzt4tRa4BWgzqVD8gYRp +wa0EYmFcA7OR3lQbenSyOMgre0oHFgGA0eMNs7CRctqA2dR4vyZ7IDS4nwgHnqDK +pxxwUvuKdWsceVWhgAjZQj5iRtvDK8Fi0XDCFekCgYALTU1v5iMIpaRAe+eyA2B1 +42qm4B/uhXznvOu2YXU6iJFmMgHGYgpa+Dq8uUjKtpn/LIFeX1KN0hH8z/0LW3gB +e7tN7taW0oLK3RQcEMfkZ7diE9x3LGqo/xMxsZMtxAr88p5eMEU/nxxznOqq+W9b +qxRbXYzEtHz+cW9+FZkyVw== +-----END PRIVATE KEY-----""" + +public_key = """-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAszSWvSSMUKTrUM/FEClw +z0RBfQ8gZYhrt7j1qOgzJ5Dpu4aIdvpzq8q1g6ljhWlSv/5+5JbR11BG5lN3sYdP +9Iln88dgGzayNOHLAL1v4Ubx2xwZ1U6LUsc6k3dxEpKXEluMVbs/WgnBUZFt7p0g +jt3xdC4fCbMvTfwk+qE58BvmaXhrx1W4fcX0eVsOswO8/AOgde4W83THf96lD4o8 +iG+bHuHFlbRoP2SdF9bMp/bvZKcFI4R5clh/3rDER8T7luPQT2T4YSrNfrf/+MYG +XEV+deahaj0thLWIGQH4ezN00b/Lgksy+E0/vJGvSFMCnVekxWqh9u2wBisiUHxm +kwIDAQAB +-----END PUBLIC KEY-----""" + + +@pytest.fixture +def config_system(db): + Config.system = Config.SystemOptions( + system_actor_private_key=private_key, system_actor_public_key=public_key + ) + yield Config.system diff --git a/users/tests/models/test_identity.py b/users/tests/models/test_identity.py index 868894a..738abe3 100644 --- a/users/tests/models/test_identity.py +++ b/users/tests/models/test_identity.py @@ -96,7 +96,7 @@ def test_identity_max_per_user(client): @pytest.mark.django_db -def test_fetch_actor(httpx_mock): +def test_fetch_actor(httpx_mock, config_system): """ Ensures that making identities via actor fetching works """ diff --git a/users/views/activitypub.py b/users/views/activitypub.py index c0fcd98..bb52f8a 100644 --- a/users/views/activitypub.py +++ b/users/views/activitypub.py @@ -18,7 +18,7 @@ from core.signatures import ( VerificationFormatError, ) from takahe import __version__ -from users.models import Identity, InboxMessage +from users.models import Identity, InboxMessage, SystemActor from users.shortcuts import by_handle_or_404 @@ -96,28 +96,52 @@ class Webfinger(View): resource = request.GET.get("resource") if not resource.startswith("acct:"): raise Http404("Not an account resource") - handle = resource[5:].replace("testfedi", "feditest") - identity = by_handle_or_404(request, handle) - return JsonResponse( - { - "subject": f"acct:{identity.handle}", - "aliases": [ - str(identity.urls.view_nice), - ], - "links": [ - { - "rel": "http://webfinger.net/rel/profile-page", - "type": "text/html", - "href": str(identity.urls.view_nice), - }, - { - "rel": "self", - "type": "application/activity+json", - "href": identity.actor_uri, - }, - ], - } - ) + handle = resource[5:] + if handle.startswith("__system__@"): + # They are trying to webfinger the system actor + system_actor = SystemActor() + return JsonResponse( + { + "subject": f"acct:{handle}", + "aliases": [ + system_actor.profile_uri, + ], + "links": [ + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": system_actor.profile_uri, + }, + { + "rel": "self", + "type": "application/activity+json", + "href": system_actor.actor_uri, + }, + ], + } + ) + else: + identity = by_handle_or_404(request, handle) + return JsonResponse( + { + "subject": f"acct:{identity.handle}", + "aliases": [ + str(identity.urls.view_nice), + ], + "links": [ + { + "rel": "http://webfinger.net/rel/profile-page", + "type": "text/html", + "href": str(identity.urls.view_nice), + }, + { + "rel": "self", + "type": "application/activity+json", + "href": identity.actor_uri, + }, + ], + } + ) @method_decorator(csrf_exempt, name="dispatch") @@ -171,3 +195,17 @@ class Inbox(View): # Hand off the item to be processed by the queue InboxMessage.objects.create(message=document) return HttpResponse(status=202) + + +class SystemActorView(View): + """ + Special endpoint for the overall system actor + """ + + def get(self, request): + return JsonResponse( + canonicalise( + SystemActor().to_ap(), + include_security=True, + ) + ) diff --git a/users/views/identity.py b/users/views/identity.py index 4dae6d5..b96d2eb 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -161,6 +161,10 @@ class CreateIdentity(FormView): raise forms.ValidationError( "This username is restricted to administrators only." ) + if value in ["__system__"]: + raise forms.ValidationError( + "This username is reserved for system use." + ) # Validate it's all ascii characters for character in value: -- cgit v1.2.3