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 --- users/models/__init__.py | 1 + users/models/identity.py | 102 ++++++++++++++++++------------------------- users/models/system_actor.py | 68 +++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 59 deletions(-) create mode 100644 users/models/system_actor.py (limited to 'users/models') 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, + ) -- cgit v1.2.3