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 = {
"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"],
)

View File

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

View File

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

View File

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