from django.db import models from celery import shared_task import logging import json import uuid import re from django.conf import settings from urllib.parse import urlparse import django_kepi.find import django_kepi.models.thing import django.core.exceptions from httpsig.verify import HeaderVerifier logger = logging.getLogger(name='django_kepi') # When we receive a message, M, in an inbox, we call validate(M). # # MESSAGE RECEIVED: # Cases: # 1) The claimed sender is a local user (i.e. their host is in ALLOWED_HOSTS). # 2) The claimed sender is remote, and we have their key cached. # 3) The claimed sender is remote, and we know that their account was closed. # (We know this because requesting their details has resulted in # a 410 error in the past.) # 4) The claimed sender is remote, we have no information stored about # their key, and the claimed key obviously belongs to # the user. (This means, at present, that the key is in the # same remote document as the user's profile.) # 5) The claimed sender is remote, we have no information stored about # their key, and the claimed key doesn't obviously belong to # the user. # # Behaviour: # 1) Request the local user's key from the class which is handling Person. # Then go to VALIDATION below. # 2) Go to VALIDATION below. # 3) Drop the message. # 4) Set our "waiting_for" record to the URL we need. # Save our IncomingMessage object. # If there is no existing request for that URL, create a background task # to retrieve its contents. Then go to BACKGROUND TASK FINISHED # below. # 5) Report an error and drop the message. # # VALIDATION: # Cases: # 1) The message passes validation. # 2) The message doesn't pass validation. # Behaviour: # 1) Call handle(M). # 2) Drop the message. # # BACKGROUND TASK FINISHED: # Cases: # 1) We now have a remote user's key. # Cache the key; # For all IncomingMessages which are waiting on that key: # Pass it through to VALIDATION above. # Delete the IncomingMessage. # 2) The remote user doesn't exist (410 Gone, or the host doesn't exist) # Store a blank in the key cache; # Drop the message. # 3) Network issues. # If there's been fewer than "n" tries, recreate the background task. # Otherwise, report the error and drop the message. class IncomingMessage(models.Model): id = models.UUIDField( primary_key=True, default=uuid.uuid4, editable=False, ) received_date = models.DateTimeField(auto_now_add=True, blank=True) content_type = models.CharField(max_length=255, default='') date = models.CharField(max_length=255, default='') digest = models.CharField(max_length=255, default='') host = models.CharField(max_length=255, default='') path = models.CharField(max_length=255, default='') signature = models.CharField(max_length=255, default='') body = models.TextField(default='') actor = models.CharField(max_length=255, default='') key_id = models.CharField(max_length=255, default='') waiting_for = models.URLField(default=None, null=True) @property def actor(self): return self.fields['actor'] @property def key_id(self): if not self.signature: logger.debug("%s: -- message has no signature", self) raise ValueError("Can't get the key ID because this message isn't signed") try: return re.findall(r'keyId="([^"]*)"', self.signature)[0] except IndexError: logger.debug("%s: -- message's signature has no keyID", self) raise ValueError("Key ID not found in %s" % (self.signature,)) def __str__(self): return str(self.id) @property def fields(self): return json.loads(self.body) @property def activity_form(self): return self.fields @shared_task() def validate( message_id, ): logger.info('%s: begin validation', message_id) try: message = IncomingMessage.objects.get(id=message_id) except django.core.exceptions.ValidationError: # This is because celery tasks are loosely coupled to # the rest of the application, so we pass in only # primitive types. raise ValueError("validate()'s message_id parameter takes a UUID string") try: key_id = message.key_id except ValueError: logger.warn('%s: message is unsigned; dropping', message) return None actor = django_kepi.find.find(message.actor) logger.debug('%s: message signature is: %s', message, message.signature) logger.debug('%s: message body is: %s', message, message.body) logger.debug('%s: actor details are: %s', message, actor) if actor is None: logger.info('%s: actor %s does not exist; dropping message', message, actor) return None # XXX key used to sign must "_obviously_belong_to" the actor key = actor['publicKey'] logger.debug('%s: public key is: %s', message, key) hv = HeaderVerifier( headers = { 'Content-Type': message.content_type, 'Date': message.date, 'Signature': message.signature, 'Host': message.host, }, secret = key['publicKeyPem'], method = 'POST', path = message.path, host = message.host, sign_header = 'Signature', ) logger.debug('%s', { 'Content-Type': message.content_type, 'Date': message.date, 'Signature': message.signature, 'Host': message.host, 'path': message.path, },) if not hv.verify(): logger.info('%s: spoofing attempt; message dropped', message) return None logger.debug('%s: validation passed!', message) result = django_kepi.models.thing.Thing.create( value=message.activity_form, sender=actor, ) logger.debug('%s: produced new Thing %s', message, result ) return result