summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--core/ld.py60
-rw-r--r--users/admin.py3
-rw-r--r--users/models/identity.py106
-rw-r--r--users/views/identity.py53
4 files changed, 124 insertions, 98 deletions
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
- """
-
- 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
-
+ Given an ActivityPub JSON-LD document, round-trips it through the LD
+ systems to end up in a canonicalised, compacted format.
-class LDItem:
+ For most well-structured incoming data this won't actually do anything,
+ but it's probably good to abide by the spec.
"""
- 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):