Move to the more sensible JSON-LD repr

This commit is contained in:
Andrew Godwin 2022-11-06 00:07:38 -06:00
parent a2404e01cd
commit 8aec395331
4 changed files with 125 additions and 99 deletions

View File

@ -6,7 +6,7 @@ from pyld.jsonld import JsonLdError
schemas = { schemas = {
"www.w3.org/ns/activitystreams": { "www.w3.org/ns/activitystreams": {
"contentType": "application/ld+json", "contentType": "application/ld+json",
"documentUrl": "https://www.w3.org/ns/activitystreams", "documentUrl": "http://www.w3.org/ns/activitystreams",
"contextUrl": None, "contextUrl": None,
"document": { "document": {
"@context": { "@context": {
@ -177,7 +177,7 @@ schemas = {
}, },
"w3id.org/security/v1": { "w3id.org/security/v1": {
"contentType": "application/ld+json", "contentType": "application/ld+json",
"documentUrl": "https://w3id.org/security/v1", "documentUrl": "http://w3id.org/security/v1",
"contextUrl": None, "contextUrl": None,
"document": { "document": {
"@context": { "@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.
""" """
if not isinstance(json_data, (dict, list)):
def __init__(self, json_data): raise ValueError("Pass decoded JSON data into LDDocument")
self.items = {} return jsonld.compact(
for entry in jsonld.flatten(jsonld.expand(json_data)): jsonld.expand(json_data),
item = LDItem(self, entry) ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
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

View File

@ -15,4 +15,5 @@ class UserEventAdmin(admin.ModelAdmin):
@admin.register(Identity) @admin.register(Identity)
class IdentityAdmin(admin.ModelAdmin): class IdentityAdmin(admin.ModelAdmin):
pass
list_display = ["id", "handle", "name", "local"]

View File

@ -5,6 +5,7 @@ from functools import partial
import httpx import httpx
import urlman import urlman
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa from cryptography.hazmat.primitives.asymmetric import padding, rsa
from django.conf import settings from django.conf import settings
@ -12,7 +13,7 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.http import http_date from django.utils.http import http_date
from core.ld import LDDocument from core.ld import canonicalise
def upload_namer(prefix, instance, filename): def upload_namer(prefix, instance, filename):
@ -34,7 +35,7 @@ class Identity(models.Model):
name = models.CharField(max_length=500, blank=True, null=True) name = models.CharField(max_length=500, blank=True, null=True)
summary = models.TextField(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) profile_uri = models.CharField(max_length=500, blank=True, null=True)
inbox_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) 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) fetched = models.DateTimeField(null=True, blank=True)
deleted = models.DateTimeField(null=True, blank=True) deleted = models.DateTimeField(null=True, blank=True)
class Meta:
verbose_name_plural = "identities"
@classmethod @classmethod
def by_handle(cls, handle, create=True): def by_handle(cls, handle, create=True):
if handle.startswith("@"): if handle.startswith("@"):
@ -72,6 +76,13 @@ class Identity(models.Model):
return cls.objects.create(handle=handle, local=False) return cls.objects.create(handle=handle, local=False)
return None return None
@classmethod
def by_actor_uri(cls, uri):
try:
cls.objects.filter(actor_uri=uri)
except cls.DoesNotExist:
return None
@property @property
def short_handle(self): def short_handle(self):
if self.handle.endswith("@" + settings.DEFAULT_DOMAIN): if self.handle.endswith("@" + settings.DEFAULT_DOMAIN):
@ -155,35 +166,53 @@ class Identity(models.Model):
) )
if response.status_code >= 400: if response.status_code >= 400:
return False return False
data = response.json() document = canonicalise(response.json())
document = LDDocument(data) self.name = document.get("name")
for person in document.by_type( self.inbox_uri = document.get("inbox")
"https://www.w3.org/ns/activitystreams#Person" self.outbox_uri = document.get("outbox")
): self.summary = document.get("summary")
self.name = person.get("https://www.w3.org/ns/activitystreams#name") self.manually_approves_followers = document.get(
self.summary = person.get( "as:manuallyApprovesFollowers"
"https://www.w3.org/ns/activitystreams#summary" )
) self.public_key = document.get("publicKey", {}).get("publicKeyPem")
self.inbox_uri = person.get("http://www.w3.org/ns/ldp#inbox") self.icon_uri = document.get("icon", {}).get("url")
self.outbox_uri = person.get( self.image_uri = document.get("image", {}).get("url")
"https://www.w3.org/ns/activitystreams#outbox" return True
)
self.manually_approves_followers = person.get( def sign(self, cleartext: str) -> str:
"https://www.w3.org/ns/activitystreams#manuallyApprovesFollowers'" if not self.private_key:
) raise ValueError("Cannot sign - no private key")
self.private_key = person.get( private_key = serialization.load_pem_private_key(
"https://w3id.org/security#publicKey" self.private_key,
).get("https://w3id.org/security#publicKeyPem") password=None,
icon = person.get("https://www.w3.org/ns/activitystreams#icon") )
if icon: return base64.b64encode(
self.icon_uri = icon.get( private_key.sign(
"https://www.w3.org/ns/activitystreams#url" cleartext,
) padding.PSS(
image = person.get("https://www.w3.org/ns/activitystreams#image") mgf=padding.MGF1(hashes.SHA256()),
if image: salt_length=padding.PSS.MAX_LENGTH,
self.image_uri = image.get( ),
"https://www.w3.org/ns/activitystreams#url" 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 return True
async def signed_request(self, host, method, path, document): 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 Delivers the document to the specified host, method, path and signed
as this user. as this user.
""" """
private_key = serialization.load_pem_private_key(
self.private_key,
password=None,
)
date_string = http_date(timezone.now().timestamp()) date_string = http_date(timezone.now().timestamp())
headers = { headers = {
"(request-target)": f"{method} {path}", "(request-target)": f"{method} {path}",
@ -203,16 +228,7 @@ class Identity(models.Model):
} }
headers_string = " ".join(headers.keys()) headers_string = " ".join(headers.keys())
signed_string = "\n".join(f"{name}: {value}" for name, value in headers.items()) signed_string = "\n".join(f"{name}: {value}" for name, value in headers.items())
signature = base64.b64encode( signature = self.sign(signed_string)
private_key.sign(
signed_string,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH,
),
hashes.SHA256(),
)
)
del headers["(request-target)"] del headers["(request-target)"]
headers[ headers[
"Signature" "Signature"

View File

@ -1,15 +1,19 @@
import base64
import json
import string import string
from cryptography.hazmat.primitives import hashes
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required 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.shortcuts import redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic import FormView, TemplateView, View from django.views.generic import FormView, TemplateView, View
from core.forms import FormHelper from core.forms import FormHelper
from core.ld import canonicalise
from miniq.models import Task from miniq.models import Task
from users.models import Identity from users.models import Identity
from users.shortcuts import by_handle_or_404 from users.shortcuts import by_handle_or_404
@ -132,10 +136,49 @@ class Inbox(View):
""" """
def post(self, request, handle): def post(self, request, handle):
# Validate the signature if "HTTP_SIGNATURE" not in request.META:
signature = request.META.get("HTTP_SIGNATURE") print("No signature")
print(signature) return HttpResponseBadRequest()
print(request.body) # 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): class Webfinger(View):