Add a system actor to sign outgoing S2S GETs

This commit is contained in:
Andrew Godwin 2022-11-20 18:29:19 -07:00
parent bed5c7ffaa
commit 5ddce16213
12 changed files with 293 additions and 93 deletions

View File

@ -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" DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ"

View File

@ -154,6 +154,9 @@ class Config(models.Model):
version: str = __version__ version: str = __version__
system_actor_public_key: str = ""
system_actor_private_key: str = ""
site_name: str = "Takahē" site_name: str = "Takahē"
highlight_color: str = "#449c8c" highlight_color: str = "#449c8c"
site_about: str = "<h2>Welcome!</h2>\n\nThis is a community running Takahē." site_about: str = "<h2>Welcome!</h2>\n\nThis is a community running Takahē."

View File

@ -1,10 +1,11 @@
import base64 import base64
import json import json
from typing import Dict, List, Literal, TypedDict from typing import Dict, List, Literal, Optional, Tuple, TypedDict
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx 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.http import HttpRequest
from django.utils import timezone from django.utils import timezone
from django.utils.http import http_date, parse_http_date from django.utils.http import http_date, parse_http_date
@ -30,6 +31,32 @@ class VerificationFormatError(VerificationError):
pass 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: class HttpSignature:
""" """
Allows for calculation and verification of HTTP signatures Allows for calculation and verification of HTTP signatures
@ -138,28 +165,37 @@ class HttpSignature:
@classmethod @classmethod
async def signed_request( async def signed_request(
self, cls,
uri: str, uri: str,
body: Dict, body: Optional[Dict],
private_key: str, private_key: str,
key_id: str, key_id: str,
content_type: str = "application/json", 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 Performs an async request to the given path, with a document, signed
as an identity. as an identity.
""" """
# Create the core header field set
uri_parts = urlparse(uri) uri_parts = urlparse(uri)
date_string = http_date() date_string = http_date()
body_bytes = json.dumps(body).encode("utf8")
headers = { headers = {
"(request-target)": f"{method} {uri_parts.path}", "(request-target)": f"{method} {uri_parts.path}",
"Host": uri_parts.hostname, "Host": uri_parts.hostname,
"Date": date_string, "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( signed_string = "\n".join(
f"{name.lower()}: {value}" for name, value in headers.items() f"{name.lower()}: {value}" for name, value in headers.items()
) )
@ -172,7 +208,7 @@ class HttpSignature:
signed_string.encode("ascii"), signed_string.encode("ascii"),
"sha256", "sha256",
) )
headers["Signature"] = self.compile_signature( headers["Signature"] = cls.compile_signature(
{ {
"keyid": key_id, "keyid": key_id,
"headers": list(headers.keys()), "headers": list(headers.keys()),
@ -180,6 +216,7 @@ class HttpSignature:
"algorithm": "rsa-sha256", "algorithm": "rsa-sha256",
} }
) )
# Send the request with all those headers except the pseudo one
del headers["(request-target)"] del headers["(request-target)"]
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.request( response = await client.request(
@ -187,6 +224,7 @@ class HttpSignature:
uri, uri,
headers=headers, headers=headers,
content=body_bytes, content=body_bytes,
follow_redirects=method == "get",
) )
if response.status_code >= 400: if response.status_code >= 400:
raise ValueError( raise ValueError(

View File

@ -104,11 +104,12 @@ urlpatterns = [
path("@<handle>/activate/", identity.ActivateIdentity.as_view()), path("@<handle>/activate/", identity.ActivateIdentity.as_view()),
path("identity/select/", identity.SelectIdentity.as_view()), path("identity/select/", identity.SelectIdentity.as_view()),
path("identity/create/", identity.CreateIdentity.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/webfinger", activitypub.Webfinger.as_view()),
path(".well-known/host-meta", activitypub.HostMeta.as_view()), path(".well-known/host-meta", activitypub.HostMeta.as_view()),
path(".well-known/nodeinfo", activitypub.NodeInfo.as_view()), path(".well-known/nodeinfo", activitypub.NodeInfo.as_view()),
path("nodeinfo/2.0/", activitypub.NodeInfo2.as_view()), path("nodeinfo/2.0/", activitypub.NodeInfo2.as_view()),
path("actor/", activitypub.SystemActorView.as_view()),
# Django admin # Django admin
path("djadmin/", djadmin.site.urls), path("djadmin/", djadmin.site.urls),
# Media files # Media files

View File

@ -4,3 +4,9 @@ from django.apps import AppConfig
class UsersConfig(AppConfig): class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "users" name = "users"
def ready(self) -> None:
# Generate the server actor keypair if needed
from users.models import SystemActor
SystemActor.generate_keys_if_needed()

View File

@ -5,5 +5,6 @@ from .identity import Identity, IdentityStates # noqa
from .inbox_message import InboxMessage, InboxMessageStates # noqa from .inbox_message import InboxMessage, InboxMessageStates # noqa
from .invite import Invite # noqa from .invite import Invite # noqa
from .password_reset import PasswordReset # noqa from .password_reset import PasswordReset # noqa
from .system_actor import SystemActor # noqa
from .user import User # noqa from .user import User # noqa
from .user_event import UserEvent # noqa from .user_event import UserEvent # noqa

View File

@ -5,8 +5,6 @@ from urllib.parse import urlparse
import httpx import httpx
import urlman import urlman
from asgiref.sync import async_to_sync, sync_to_async 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.db import models
from django.template.defaultfilters import linebreaks_filter from django.template.defaultfilters import linebreaks_filter
from django.templatetags.static import static from django.templatetags.static import static
@ -15,9 +13,11 @@ from django.utils import timezone
from core.exceptions import ActorMismatchError from core.exceptions import ActorMismatchError
from core.html import sanitize_post from core.html import sanitize_post
from core.ld import canonicalise, media_type_from_filename from core.ld import canonicalise, media_type_from_filename
from core.signatures import RsaKeys
from core.uploads import upload_namer from core.uploads import upload_namer
from stator.models import State, StateField, StateGraph, StatorModel from stator.models import State, StateField, StateGraph, StatorModel
from users.models.domain import Domain from users.models.domain import Domain
from users.models.system_actor import SystemActor
class IdentityStates(StateGraph): class IdentityStates(StateGraph):
@ -301,15 +301,16 @@ class Identity(StatorModel):
""" """
domain = handle.split("@")[1] domain = handle.split("@")[1]
try: try:
async with httpx.AsyncClient() as client: response = await SystemActor().signed_request(
response = await client.get( method="get",
f"https://{domain}/.well-known/webfinger?resource=acct:{handle}", uri=f"https://{domain}/.well-known/webfinger?resource=acct:{handle}",
headers={"Accept": "application/json"}, )
follow_redirects=True,
)
except httpx.RequestError: except httpx.RequestError:
return None, None 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 return None, None
data = response.json() data = response.json()
if data["subject"].startswith("acct:"): if data["subject"].startswith("acct:"):
@ -329,40 +330,39 @@ class Identity(StatorModel):
""" """
if self.local: if self.local:
raise ValueError("Cannot fetch local identities") raise ValueError("Cannot fetch local identities")
async with httpx.AsyncClient() as client: try:
try: response = await SystemActor().signed_request(
response = await client.get( method="get",
self.actor_uri, uri=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"
) )
self.public_key = document.get("publicKey", {}).get("publicKeyPem") except httpx.RequestError:
self.public_key_id = document.get("publicKey", {}).get("id") return False
self.icon_uri = document.get("icon", {}).get("url") if response.status_code == 410:
self.image_uri = document.get("image", {}).get("url") # 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 # Now go do webfinger with that info to see if we can get a canonical domain
actor_url_parts = urlparse(self.actor_uri) actor_url_parts = urlparse(self.actor_uri)
get_domain = sync_to_async(Domain.get_remote_domain) get_domain = sync_to_async(Domain.get_remote_domain)
@ -387,22 +387,6 @@ class Identity(StatorModel):
def generate_keypair(self): def generate_keypair(self):
if not self.local: if not self.local:
raise ValueError("Cannot generate keypair for remote user") raise ValueError("Cannot generate keypair for remote user")
private_key = rsa.generate_private_key( self.private_key, self.public_key = RsaKeys.generate_keypair()
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.public_key_id = self.actor_uri + "#main-key" self.public_key_id = self.actor_uri + "#main-key"
self.save() self.save()

View File

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

51
users/tests/conftest.py Normal file
View File

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

View File

@ -96,7 +96,7 @@ def test_identity_max_per_user(client):
@pytest.mark.django_db @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 Ensures that making identities via actor fetching works
""" """

View File

@ -18,7 +18,7 @@ from core.signatures import (
VerificationFormatError, VerificationFormatError,
) )
from takahe import __version__ 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 from users.shortcuts import by_handle_or_404
@ -96,28 +96,52 @@ class Webfinger(View):
resource = request.GET.get("resource") resource = request.GET.get("resource")
if not resource.startswith("acct:"): if not resource.startswith("acct:"):
raise Http404("Not an account resource") raise Http404("Not an account resource")
handle = resource[5:].replace("testfedi", "feditest") handle = resource[5:]
identity = by_handle_or_404(request, handle) if handle.startswith("__system__@"):
return JsonResponse( # They are trying to webfinger the system actor
{ system_actor = SystemActor()
"subject": f"acct:{identity.handle}", return JsonResponse(
"aliases": [ {
str(identity.urls.view_nice), "subject": f"acct:{handle}",
], "aliases": [
"links": [ system_actor.profile_uri,
{ ],
"rel": "http://webfinger.net/rel/profile-page", "links": [
"type": "text/html", {
"href": str(identity.urls.view_nice), "rel": "http://webfinger.net/rel/profile-page",
}, "type": "text/html",
{ "href": system_actor.profile_uri,
"rel": "self", },
"type": "application/activity+json", {
"href": identity.actor_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") @method_decorator(csrf_exempt, name="dispatch")
@ -171,3 +195,17 @@ class Inbox(View):
# Hand off the item to be processed by the queue # Hand off the item to be processed by the queue
InboxMessage.objects.create(message=document) InboxMessage.objects.create(message=document)
return HttpResponse(status=202) 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,
)
)

View File

@ -161,6 +161,10 @@ class CreateIdentity(FormView):
raise forms.ValidationError( raise forms.ValidationError(
"This username is restricted to administrators only." "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 # Validate it's all ascii characters
for character in value: for character in value: