diff --git a/core/ld.py b/core/ld.py index 7d4167c..38e436a 100644 --- a/core/ld.py +++ b/core/ld.py @@ -6,7 +6,7 @@ from pyld.jsonld import JsonLdError schemas = { "www.w3.org/ns/activitystreams": { "contentType": "application/ld+json", - "documentUrl": "https://www.w3.org/ns/activitystreams", + "documentUrl": "http://www.w3.org/ns/activitystreams", "contextUrl": None, "document": { "@context": { @@ -177,7 +177,7 @@ schemas = { }, "w3id.org/security/v1": { "contentType": "application/ld+json", - "documentUrl": "https://w3id.org/security/v1", + "documentUrl": "http://w3id.org/security/v1", "contextUrl": None, "document": { "@context": { @@ -252,51 +252,17 @@ def builtin_document_loader(url: str, options={}): ) -class LDDocument: +def canonicalise(json_data): """ - Utility class for dealing with a document a bit more easily + Given an ActivityPub JSON-LD document, round-trips it through the LD + systems to end up in a canonicalised, compacted format. + + For most well-structured incoming data this won't actually do anything, + but it's probably good to abide by the spec. """ - - def __init__(self, json_data): - self.items = {} - for entry in jsonld.flatten(jsonld.expand(json_data)): - item = LDItem(self, entry) - self.items[item.id] = item - - def by_type(self, type): - for item in self.items.values(): - if item.type == type: - yield item - - -class LDItem: - """ - Represents a single item in an LDDocument - """ - - def __init__(self, document, data): - self.data = data - self.document = document - self.id = self.data["@id"] - if "@type" in self.data: - self.type = self.data["@type"][0] - else: - self.type = None - - def get(self, key): - """ - Gets the first value of the given key, or None if it's not present. - If it's an ID reference, returns the other Item if possible, or the raw - ID if it's not supplied. - """ - contents = self.data.get(key) - if not contents: - return None - id = contents[0].get("@id") - value = contents[0].get("@value") - if value is not None: - return value - if id in self.document.items: - return self.document.items[id] - else: - return id + if not isinstance(json_data, (dict, list)): + raise ValueError("Pass decoded JSON data into LDDocument") + return jsonld.compact( + jsonld.expand(json_data), + ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"], + ) diff --git a/users/admin.py b/users/admin.py index 6ae97b9..e5db9d1 100644 --- a/users/admin.py +++ b/users/admin.py @@ -15,4 +15,5 @@ class UserEventAdmin(admin.ModelAdmin): @admin.register(Identity) class IdentityAdmin(admin.ModelAdmin): - pass + + list_display = ["id", "handle", "name", "local"] diff --git a/users/models/identity.py b/users/models/identity.py index 5586f27..3aa4545 100644 --- a/users/models/identity.py +++ b/users/models/identity.py @@ -5,6 +5,7 @@ from functools import partial import httpx import urlman from asgiref.sync import sync_to_async +from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa from django.conf import settings @@ -12,7 +13,7 @@ from django.db import models from django.utils import timezone from django.utils.http import http_date -from core.ld import LDDocument +from core.ld import canonicalise def upload_namer(prefix, instance, filename): @@ -34,7 +35,7 @@ class Identity(models.Model): name = models.CharField(max_length=500, blank=True, null=True) summary = models.TextField(blank=True, null=True) - actor_uri = models.CharField(max_length=500, blank=True, null=True) + actor_uri = models.CharField(max_length=500, blank=True, null=True, db_index=True) profile_uri = models.CharField(max_length=500, blank=True, null=True) inbox_uri = models.CharField(max_length=500, blank=True, null=True) outbox_uri = models.CharField(max_length=500, blank=True, null=True) @@ -59,6 +60,9 @@ class Identity(models.Model): fetched = models.DateTimeField(null=True, blank=True) deleted = models.DateTimeField(null=True, blank=True) + class Meta: + verbose_name_plural = "identities" + @classmethod def by_handle(cls, handle, create=True): if handle.startswith("@"): @@ -72,6 +76,13 @@ class Identity(models.Model): return cls.objects.create(handle=handle, local=False) return None + @classmethod + def by_actor_uri(cls, uri): + try: + cls.objects.filter(actor_uri=uri) + except cls.DoesNotExist: + return None + @property def short_handle(self): if self.handle.endswith("@" + settings.DEFAULT_DOMAIN): @@ -155,35 +166,53 @@ class Identity(models.Model): ) if response.status_code >= 400: return False - data = response.json() - document = LDDocument(data) - for person in document.by_type( - "https://www.w3.org/ns/activitystreams#Person" - ): - self.name = person.get("https://www.w3.org/ns/activitystreams#name") - self.summary = person.get( - "https://www.w3.org/ns/activitystreams#summary" - ) - self.inbox_uri = person.get("http://www.w3.org/ns/ldp#inbox") - self.outbox_uri = person.get( - "https://www.w3.org/ns/activitystreams#outbox" - ) - self.manually_approves_followers = person.get( - "https://www.w3.org/ns/activitystreams#manuallyApprovesFollowers'" - ) - self.private_key = person.get( - "https://w3id.org/security#publicKey" - ).get("https://w3id.org/security#publicKeyPem") - icon = person.get("https://www.w3.org/ns/activitystreams#icon") - if icon: - self.icon_uri = icon.get( - "https://www.w3.org/ns/activitystreams#url" - ) - image = person.get("https://www.w3.org/ns/activitystreams#image") - if image: - self.image_uri = image.get( - "https://www.w3.org/ns/activitystreams#url" - ) + document = canonicalise(response.json()) + self.name = document.get("name") + self.inbox_uri = document.get("inbox") + self.outbox_uri = document.get("outbox") + self.summary = document.get("summary") + self.manually_approves_followers = document.get( + "as:manuallyApprovesFollowers" + ) + self.public_key = document.get("publicKey", {}).get("publicKeyPem") + self.icon_uri = document.get("icon", {}).get("url") + self.image_uri = document.get("image", {}).get("url") + return True + + def sign(self, cleartext: str) -> str: + if not self.private_key: + raise ValueError("Cannot sign - no private key") + private_key = serialization.load_pem_private_key( + self.private_key, + password=None, + ) + return base64.b64encode( + private_key.sign( + cleartext, + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.MAX_LENGTH, + ), + hashes.SHA256(), + ) + ).decode("ascii") + + def verify_signature(self, crypttext: str, cleartext: str) -> bool: + if not self.public_key: + raise ValueError("Cannot verify - no private key") + public_key = serialization.load_pem_public_key(self.public_key) + try: + public_key.verify( + crypttext, + cleartext, + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.MAX_LENGTH, + ), + hashes.SHA256(), + ) + except InvalidSignature: + return False return True async def signed_request(self, host, method, path, document): @@ -191,10 +220,6 @@ class Identity(models.Model): Delivers the document to the specified host, method, path and signed as this user. """ - private_key = serialization.load_pem_private_key( - self.private_key, - password=None, - ) date_string = http_date(timezone.now().timestamp()) headers = { "(request-target)": f"{method} {path}", @@ -203,16 +228,7 @@ class Identity(models.Model): } headers_string = " ".join(headers.keys()) signed_string = "\n".join(f"{name}: {value}" for name, value in headers.items()) - signature = base64.b64encode( - private_key.sign( - signed_string, - padding.PSS( - mgf=padding.MGF1(hashes.SHA256()), - salt_length=padding.PSS.MAX_LENGTH, - ), - hashes.SHA256(), - ) - ) + signature = self.sign(signed_string) del headers["(request-target)"] headers[ "Signature" diff --git a/users/views/identity.py b/users/views/identity.py index 456fead..d8f241f 100644 --- a/users/views/identity.py +++ b/users/views/identity.py @@ -1,15 +1,19 @@ +import base64 +import json import string +from cryptography.hazmat.primitives import hashes from django import forms from django.conf import settings from django.contrib.auth.decorators import login_required -from django.http import Http404, JsonResponse +from django.http import Http404, HttpResponseBadRequest, JsonResponse from django.shortcuts import redirect from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import FormView, TemplateView, View from core.forms import FormHelper +from core.ld import canonicalise from miniq.models import Task from users.models import Identity from users.shortcuts import by_handle_or_404 @@ -132,10 +136,49 @@ class Inbox(View): """ def post(self, request, handle): - # Validate the signature - signature = request.META.get("HTTP_SIGNATURE") - print(signature) - print(request.body) + if "HTTP_SIGNATURE" not in request.META: + print("No signature") + return HttpResponseBadRequest() + # Split apart signature + signature_details = {} + for item in request.META["HTTP_SIGNATURE"].split(","): + name, value = item.split("=", 1) + value = value.strip('"') + signature_details[name] = value + # Reject unknown algorithms + if signature_details["algorithm"] != "rsa-sha256": + print("Unknown algorithm") + return HttpResponseBadRequest() + # Calculate body digest + if "HTTP_DIGEST" in request.META: + digest = hashes.Hash(hashes.SHA256()) + digest.update(request.body) + digest_header = "SHA-256=" + base64.b64encode(digest.finalize()).decode( + "ascii" + ) + if request.META["HTTP_DIGEST"] != digest_header: + print("Bad digest") + return HttpResponseBadRequest() + # Create the signature payload + headers = {} + for header_name in signature_details["headers"].split(): + if header_name == "(request-target)": + value = f"post {request.path}" + elif header_name == "content-type": + value = request.META["CONTENT_TYPE"] + else: + value = request.META[f"HTTP_{header_name.upper()}"] + headers[header_name] = value + signed_string = "\n".join(f"{name}: {value}" for name, value in headers.items()) + # Load the LD + document = canonicalise(json.loads(request.body)) + print(document) + # Find the Identity by the actor on the incoming item + identity = Identity.by_actor_uri(document["actor"]) + if not identity.verify_signature(signature_details["signature"], signed_string): + print("Bad signature") + return HttpResponseBadRequest() + return JsonResponse({"status": "OK"}) class Webfinger(View):