This repository has been archived on 2023-09-24. You can view files and clone it, but cannot push or open issues or pull requests.
takahe/users/models/identity.py

302 lines
11 KiB
Python
Raw Normal View History

2022-11-05 21:17:27 +01:00
import base64
import uuid
from functools import partial
from typing import Optional, Tuple
from urllib.parse import urlparse
2022-11-05 21:17:27 +01:00
2022-11-06 00:51:54 +01:00
import httpx
2022-11-05 21:17:27 +01:00
import urlman
2022-11-06 00:51:54 +01:00
from asgiref.sync import sync_to_async
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding, rsa
2022-11-05 21:17:27 +01:00
from django.db import models
from django.utils import timezone
2022-11-06 00:51:54 +01:00
from django.utils.http import http_date
from OpenSSL import crypto
2022-11-06 00:51:54 +01:00
2022-11-06 07:07:38 +01:00
from core.ld import canonicalise
from users.models.domain import Domain
2022-11-05 21:17:27 +01:00
def upload_namer(prefix, instance, filename):
"""
Names uploaded images etc.
"""
now = timezone.now()
filename = base64.b32encode(uuid.uuid4().bytes).decode("ascii")
return f"{prefix}/{now.year}/{now.month}/{now.day}/{filename}"
class Identity(models.Model):
"""
Represents both local and remote Fediverse identities (actors)
"""
# The Actor URI is essentially also a PK - we keep the default numeric
# one around as well for making nice URLs etc.
actor_uri = models.CharField(max_length=500, blank=True, null=True, unique=True)
local = models.BooleanField()
users = models.ManyToManyField("users.User", related_name="identities")
username = models.CharField(max_length=500, blank=True, null=True)
# Must be a display domain if present
domain = models.ForeignKey(
"users.Domain",
blank=True,
null=True,
on_delete=models.PROTECT,
)
2022-11-05 21:17:27 +01:00
name = models.CharField(max_length=500, blank=True, null=True)
2022-11-06 00:51:54 +01:00
summary = models.TextField(blank=True, null=True)
manually_approves_followers = models.BooleanField(blank=True, null=True)
2022-11-06 00:51:54 +01:00
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)
icon_uri = models.CharField(max_length=500, blank=True, null=True)
image_uri = models.CharField(max_length=500, blank=True, null=True)
2022-11-05 21:17:27 +01:00
2022-11-06 00:51:54 +01:00
icon = models.ImageField(
upload_to=partial(upload_namer, "profile_images"), blank=True, null=True
)
image = models.ImageField(
upload_to=partial(upload_namer, "background_images"), blank=True, null=True
2022-11-05 21:17:27 +01:00
)
private_key = models.TextField(null=True, blank=True)
public_key = models.TextField(null=True, blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
2022-11-06 00:51:54 +01:00
fetched = models.DateTimeField(null=True, blank=True)
2022-11-05 21:17:27 +01:00
deleted = models.DateTimeField(null=True, blank=True)
2022-11-06 07:07:38 +01:00
class Meta:
verbose_name_plural = "identities"
unique_together = [("username", "domain")]
2022-11-06 07:07:38 +01:00
2022-11-06 05:49:25 +01:00
@classmethod
def by_handle(cls, handle, fetch=False, local=False):
2022-11-06 05:49:25 +01:00
if handle.startswith("@"):
raise ValueError("Handle must not start with @")
if "@" not in handle:
raise ValueError("Handle must contain domain")
username, domain = handle.split("@")
2022-11-06 05:49:25 +01:00
try:
if local:
return cls.objects.get(username=username, domain_id=domain, local=True)
else:
return cls.objects.get(username=username, domain_id=domain)
2022-11-06 05:49:25 +01:00
except cls.DoesNotExist:
if fetch and not local:
2022-11-06 05:49:25 +01:00
return cls.objects.create(handle=handle, local=False)
return None
2022-11-06 07:07:38 +01:00
@classmethod
def by_actor_uri(cls, uri) -> Optional["Identity"]:
2022-11-06 07:07:38 +01:00
try:
return cls.objects.get(actor_uri=uri)
2022-11-06 07:07:38 +01:00
except cls.DoesNotExist:
return None
@classmethod
def by_actor_uri_with_create(cls, uri) -> "Identity":
try:
return cls.objects.get(actor_uri=uri)
except cls.DoesNotExist:
return cls.objects.create(actor_uri=uri, local=False)
2022-11-05 21:17:27 +01:00
@property
def handle(self):
return f"{self.username}@{self.domain_id}"
2022-11-05 21:17:27 +01:00
2022-11-06 05:49:25 +01:00
@property
def data_age(self) -> float:
"""
How old our copy of this data is, in seconds
"""
if self.local:
return 0
if self.fetched is None:
return 10000000000
return (timezone.now() - self.fetched).total_seconds()
2022-11-05 21:17:27 +01:00
def generate_keypair(self):
if not self.local:
raise ValueError("Cannot generate keypair for remote user")
2022-11-05 21:17:27 +01:00
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
self.private_key = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
self.public_key = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
self.save()
@classmethod
async def fetch_webfinger(cls, handle: str) -> Tuple[Optional[str], Optional[str]]:
"""
Given a username@domain handle, returns a tuple of
(actor uri, canonical handle) or None, None if it does not resolve.
"""
domain = handle.split("@")[1]
2022-11-06 00:51:54 +01:00
async with httpx.AsyncClient() as client:
response = await client.get(
f"https://{domain}/.well-known/webfinger?resource=acct:{handle}",
2022-11-06 00:51:54 +01:00
headers={"Accept": "application/json"},
2022-11-06 05:49:25 +01:00
follow_redirects=True,
2022-11-06 00:51:54 +01:00
)
if response.status_code >= 400:
return None, None
2022-11-06 00:51:54 +01:00
data = response.json()
if data["subject"].startswith("acct:"):
data["subject"] = data["subject"][5:]
2022-11-06 00:51:54 +01:00
for link in data["links"]:
if (
link.get("type") == "application/activity+json"
and link.get("rel") == "self"
):
return link["href"], data["subject"]
return None, None
2022-11-06 00:51:54 +01:00
async def fetch_actor(self) -> bool:
"""
Fetches the user's actor information, as well as their domain from
webfinger if it's available.
"""
if self.local:
raise ValueError("Cannot fetch local identities")
2022-11-06 00:51:54 +01:00
async with httpx.AsyncClient() as client:
response = await client.get(
self.actor_uri,
headers={"Accept": "application/json"},
2022-11-06 05:49:25 +01:00
follow_redirects=True,
2022-11-06 00:51:54 +01:00
)
if response.status_code >= 400:
return False
document = canonicalise(response.json(), include_security=True)
2022-11-06 07:07:38 +01:00
self.name = document.get("name")
self.profile_uri = document.get("url")
2022-11-06 07:07:38 +01:00
self.inbox_uri = document.get("inbox")
self.outbox_uri = document.get("outbox")
self.summary = document.get("summary")
self.username = document.get("preferredUsername")
2022-11-06 07:07:38 +01:00
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")
# Now go do webfinger with that info to see if we can get a canonical domain
actor_url_parts = urlparse(self.actor_uri)
get_domain = sync_to_async(Domain.get_remote_domain)
if self.username:
webfinger_actor, webfinger_handle = await self.fetch_webfinger(
f"{self.username}@{actor_url_parts.hostname}"
)
if webfinger_handle:
webfinger_username, webfinger_domain = webfinger_handle.split("@")
self.username = webfinger_username
self.domain = await get_domain(webfinger_domain)
else:
self.domain = await get_domain(actor_url_parts.hostname)
else:
self.domain = await get_domain(actor_url_parts.hostname)
self.fetched = timezone.now()
await sync_to_async(self.save)()
2022-11-06 07:07:38 +01:00
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.encode("ascii"),
2022-11-06 07:07:38 +01:00
password=None,
)
return base64.b64encode(
private_key.sign(
cleartext.encode("ascii"),
2022-11-06 07:07:38 +01:00
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH,
),
hashes.SHA256(),
)
).decode("ascii")
def verify_signature(self, signature: bytes, cleartext: str) -> bool:
2022-11-06 07:07:38 +01:00
if not self.public_key:
raise ValueError("Cannot verify - no public key")
x509 = crypto.X509()
x509.set_pubkey(
crypto.load_publickey(
crypto.FILETYPE_PEM,
self.public_key.encode("ascii"),
2022-11-06 07:07:38 +01:00
)
)
try:
crypto.verify(x509, signature, cleartext.encode("ascii"), "sha256")
except crypto.Error:
2022-11-06 07:07:38 +01:00
return False
2022-11-06 00:51:54 +01:00
return True
async def signed_request(self, host, method, path, document):
"""
Delivers the document to the specified host, method, path and signed
as this user.
"""
date_string = http_date(timezone.now().timestamp())
headers = {
"(request-target)": f"{method} {path}",
"Host": host,
"Date": date_string,
}
headers_string = " ".join(headers.keys())
signed_string = "\n".join(f"{name}: {value}" for name, value in headers.items())
2022-11-06 07:07:38 +01:00
signature = self.sign(signed_string)
2022-11-06 00:51:54 +01:00
del headers["(request-target)"]
headers[
"Signature"
] = f'keyId="{self.urls.key.full()}",headers="{headers_string}",signature="{signature}"'
2022-11-06 00:51:54 +01:00
async with httpx.AsyncClient() as client:
return await client.request(
method,
"https://{host}{path}",
headers=headers,
data=document,
)
def validate_signature(self, request):
"""
Attempts to validate the signature on an incoming request.
Returns False if the signature is invalid, None if it cannot be verified
as we do not have the key locally, or the name of the actor if it is valid.
"""
pass
2022-11-05 21:17:27 +01:00
def __str__(self):
return self.handle or self.actor_uri
2022-11-05 21:17:27 +01:00
class urls(urlman.Urls):
view = "/@{self.username}@{self.domain_id}/"
view_short = "/@{self.username}/"
2022-11-05 21:17:27 +01:00
actor = "{view}actor/"
key = "{actor}#main-key"
2022-11-05 21:17:27 +01:00
inbox = "{actor}inbox/"
outbox = "{actor}outbox/"
2022-11-05 21:17:27 +01:00
activate = "{view}activate/"
def get_scheme(self, url):
return "https"
def get_hostname(self, url):
return self.instance.domain.uri_domain