mirror of
https://github.com/jointakahe/takahe.git
synced 2024-11-22 15:21:01 +00:00
Move to the more sensible JSON-LD repr
This commit is contained in:
parent
a2404e01cd
commit
8aec395331
4 changed files with 125 additions and 99 deletions
62
core/ld.py
62
core/ld.py
|
@ -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
|
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
|
@ -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.inbox_uri = person.get("http://www.w3.org/ns/ldp#inbox")
|
self.public_key = document.get("publicKey", {}).get("publicKeyPem")
|
||||||
self.outbox_uri = person.get(
|
self.icon_uri = document.get("icon", {}).get("url")
|
||||||
"https://www.w3.org/ns/activitystreams#outbox"
|
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,
|
||||||
)
|
)
|
||||||
self.manually_approves_followers = person.get(
|
return base64.b64encode(
|
||||||
"https://www.w3.org/ns/activitystreams#manuallyApprovesFollowers'"
|
private_key.sign(
|
||||||
|
cleartext,
|
||||||
|
padding.PSS(
|
||||||
|
mgf=padding.MGF1(hashes.SHA256()),
|
||||||
|
salt_length=padding.PSS.MAX_LENGTH,
|
||||||
|
),
|
||||||
|
hashes.SHA256(),
|
||||||
)
|
)
|
||||||
self.private_key = person.get(
|
).decode("ascii")
|
||||||
"https://w3id.org/security#publicKey"
|
|
||||||
).get("https://w3id.org/security#publicKeyPem")
|
def verify_signature(self, crypttext: str, cleartext: str) -> bool:
|
||||||
icon = person.get("https://www.w3.org/ns/activitystreams#icon")
|
if not self.public_key:
|
||||||
if icon:
|
raise ValueError("Cannot verify - no private key")
|
||||||
self.icon_uri = icon.get(
|
public_key = serialization.load_pem_public_key(self.public_key)
|
||||||
"https://www.w3.org/ns/activitystreams#url"
|
try:
|
||||||
)
|
public_key.verify(
|
||||||
image = person.get("https://www.w3.org/ns/activitystreams#image")
|
crypttext,
|
||||||
if image:
|
cleartext,
|
||||||
self.image_uri = image.get(
|
padding.PSS(
|
||||||
"https://www.w3.org/ns/activitystreams#url"
|
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"
|
||||||
|
|
|
@ -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):
|
||||||
|
|
Loading…
Reference in a new issue