Move to the more sensible JSON-LD repr
This commit is contained in:
		
							parent
							
								
									a2404e01cd
								
							
						
					
					
						commit
						8aec395331
					
				
							
								
								
									
										62
									
								
								core/ld.py
									
									
									
									
									
								
							
							
						
						
									
										62
									
								
								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"],
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
@ -15,4 +15,5 @@ class UserEventAdmin(admin.ModelAdmin):
 | 
			
		||||
 | 
			
		||||
@admin.register(Identity)
 | 
			
		||||
class IdentityAdmin(admin.ModelAdmin):
 | 
			
		||||
    pass
 | 
			
		||||
 | 
			
		||||
    list_display = ["id", "handle", "name", "local"]
 | 
			
		||||
 | 
			
		||||
@ -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"
 | 
			
		||||
 | 
			
		||||
@ -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):
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user