diff --git a/fedireads/broadcast.py b/fedireads/broadcast.py index ff7dd801..05b6904e 100644 --- a/fedireads/broadcast.py +++ b/fedireads/broadcast.py @@ -1,15 +1,11 @@ ''' send out activitypub messages ''' import json -from urllib.parse import urlparse -from base64 import b64encode -from Crypto.PublicKey import RSA -from Crypto.Signature import pkcs1_15 -from Crypto.Hash import SHA256 from django.utils.http import http_date import requests from fedireads import models from fedireads.tasks import app +from fedireads.signatures import make_signature def get_public_recipients(user, software=None): @@ -63,24 +59,6 @@ def broadcast_task(sender_id, activity, recipients): return errors -def make_signature(sender, destination, date): - inbox_parts = urlparse(destination) - signature_headers = [ - '(request-target): post %s' % inbox_parts.path, - 'host: %s' % inbox_parts.netloc, - 'date: %s' % date, - ] - message_to_sign = '\n'.join(signature_headers) - signer = pkcs1_15.new(RSA.import_key(sender.private_key)) - signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8'))) - signature = { - 'keyId': '%s#main-key' % sender.remote_id, - 'algorithm': 'rsa-sha256', - 'headers': '(request-target) host date', - 'signature': b64encode(signed_message).decode('utf8'), - } - return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items()) - def sign_and_send(sender, activity, destination): ''' crpyto whatever and http junk ''' now = http_date() diff --git a/fedireads/incoming.py b/fedireads/incoming.py index 4daaf709..5e5a457e 100644 --- a/fedireads/incoming.py +++ b/fedireads/incoming.py @@ -1,21 +1,18 @@ ''' handles all of the activity coming in to the server ''' import json -from base64 import b64decode from urllib.parse import urldefrag +import requests -from Crypto.Hash import SHA256 -from Crypto.PublicKey import RSA -from Crypto.Signature import pkcs1_15 import django.db.utils from django.http import HttpResponse from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.views.decorators.csrf import csrf_exempt -import requests from fedireads import books_manager, models, outgoing from fedireads import status as status_builder from fedireads.remote_user import get_or_create_remote_user from fedireads.tasks import app +from fedireads.signatures import Signature @csrf_exempt @@ -48,7 +45,15 @@ def shared_inbox(request): return HttpResponseBadRequest() try: - verify_signature(activity.get('actor'), request) + signature = Signature.parse(request) + + key_actor = urldefrag(signature.key_id).url + if key_actor != activity.get('actor'): + raise ValueError("Wrong actor created signature.") + + key = get_public_key(key_actor) + + signature.verify(key, request) except ValueError: return HttpResponse(status=401) @@ -88,7 +93,6 @@ def get_public_key(key_actor): try: user = models.User.objects.get(remote_id=key_actor) public_key = user.public_key - actor = user.remote_id except models.User.DoesNotExist: response = requests.get( key_actor, @@ -99,49 +103,7 @@ def get_public_key(key_actor): user_data = response.json() public_key = user_data['publicKey']['publicKeyPem'] - return RSA.import_key(public_key) - -def verify_signature(required_actor, request): - ''' verify rsa signature ''' - signature_dict = {} - for pair in request.headers['Signature'].split(','): - k, v = pair.split('=', 1) - v = v.replace('"', '') - signature_dict[k] = v - - try: - key_id = signature_dict['keyId'] - headers = signature_dict['headers'] - signature = b64decode(signature_dict['signature']) - except KeyError: - raise ValueError('Invalid auth header') - - # TODO Use the fragment - actors can have multiple keys? - key_actor = urldefrag(key_id).url - - if key_actor != required_actor: - raise ValueError("Wrong actor created signature.") - - key = get_public_key(key_actor) - - comparison_string = [] - for signed_header_name in headers.split(' '): - if signed_header_name == '(request-target)': - comparison_string.append('(request-target): post %s' % request.path) - else: - comparison_string.append('%s: %s' % ( - signed_header_name, - request.headers[signed_header_name] - )) - comparison_string = '\n'.join(comparison_string) - - signer = pkcs1_15.new(key) - digest = SHA256.new() - digest.update(comparison_string.encode()) - - # raises a ValueError if it fails - signer.verify(digest, signature) - + return public_key @app.task def handle_follow(activity): diff --git a/fedireads/models/user.py b/fedireads/models/user.py index e9bb2de0..8376bd8c 100644 --- a/fedireads/models/user.py +++ b/fedireads/models/user.py @@ -1,6 +1,4 @@ ''' database schema for user data ''' -from Crypto import Random -from Crypto.PublicKey import RSA from django.contrib.auth.models import AbstractUser from django.db import models from django.dispatch import receiver @@ -8,6 +6,7 @@ from django.dispatch import receiver from fedireads import activitypub from fedireads.models.shelf import Shelf from fedireads.settings import DOMAIN +from fedireads.signatures import create_key_pair from .base_model import FedireadsModel @@ -158,10 +157,7 @@ def execute_before_save(sender, instance, *args, **kwargs): instance.shared_inbox = 'https://%s/inbox' % DOMAIN instance.outbox = '%s/outbox' % instance.remote_id if not instance.private_key: - random_generator = Random.new().read - key = RSA.generate(1024, random_generator) - instance.private_key = key.export_key().decode('utf8') - instance.public_key = key.publickey().export_key().decode('utf8') + instance.private_key, instance.public_key = create_key_pair() @receiver(models.signals.post_save, sender=User) diff --git a/fedireads/signatures.py b/fedireads/signatures.py new file mode 100644 index 00000000..d9f1a088 --- /dev/null +++ b/fedireads/signatures.py @@ -0,0 +1,82 @@ +from urllib.parse import urlparse +from base64 import b64encode, b64decode + +from Crypto import Random +from Crypto.PublicKey import RSA +from Crypto.Signature import pkcs1_15 #pylint: disable=no-name-in-module +from Crypto.Hash import SHA256 + + +def create_key_pair(): + random_generator = Random.new().read + key = RSA.generate(1024, random_generator) + private_key = key.export_key().decode('utf8') + public_key = key.publickey().export_key().decode('utf8') + + return private_key, public_key + + +def make_signature(sender, destination, date): + inbox_parts = urlparse(destination) + signature_headers = [ + '(request-target): post %s' % inbox_parts.path, + 'host: %s' % inbox_parts.netloc, + 'date: %s' % date, + ] + message_to_sign = '\n'.join(signature_headers) + signer = pkcs1_15.new(RSA.import_key(sender.private_key)) + signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8'))) + signature = { + 'keyId': '%s#main-key' % sender.remote_id, + 'algorithm': 'rsa-sha256', + 'headers': '(request-target) host date', + 'signature': b64encode(signed_message).decode('utf8'), + } + return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items()) + + +class Signature: + def __init__(self, key_id, headers, signature): + self.key_id = key_id + self.headers = headers + self.signature = signature + + @classmethod + def parse(cls, request): + signature_dict = {} + for pair in request.headers['Signature'].split(','): + k, v = pair.split('=', 1) + v = v.replace('"', '') + signature_dict[k] = v + + try: + key_id = signature_dict['keyId'] + headers = signature_dict['headers'] + signature = b64decode(signature_dict['signature']) + except KeyError: + raise ValueError('Invalid auth header') + + return cls(key_id, headers, signature) + + def verify(self, public_key, request): + ''' verify rsa signature ''' + public_key = RSA.import_key(public_key) + + comparison_string = [] + for signed_header_name in self.headers.split(' '): + if signed_header_name == '(request-target)': + comparison_string.append( + '(request-target): post %s' % request.path) + else: + comparison_string.append('%s: %s' % ( + signed_header_name, + request.headers[signed_header_name] + )) + comparison_string = '\n'.join(comparison_string) + + signer = pkcs1_15.new(public_key) + digest = SHA256.new() + digest.update(comparison_string.encode()) + + # raises a ValueError if it fails + signer.verify(digest, self.signature) diff --git a/fedireads/tests/test_signing.py b/fedireads/tests/test_signing.py index 6bf9b7e6..9cfa1311 100644 --- a/fedireads/tests/test_signing.py +++ b/fedireads/tests/test_signing.py @@ -1,18 +1,15 @@ from collections import namedtuple from urllib.parse import urlsplit -from Crypto import Random -from Crypto.PublicKey import RSA - import responses from django.test import TestCase, Client from django.utils.http import http_date from fedireads.models import User -from fedireads.broadcast import make_signature from fedireads.activitypub import get_follow_request from fedireads.settings import DOMAIN +from fedireads.signatures import create_key_pair, make_signature Sender = namedtuple('Sender', ('remote_id', 'private_key', 'public_key')) @@ -22,10 +19,7 @@ class Signature(TestCase): self.rat = User.objects.create_user('rat', 'rat@example.com', '') self.cat = User.objects.create_user('cat', 'cat@example.com', '') - random_generator = Random.new().read - key = RSA.generate(1024, random_generator) - private_key = key.export_key().decode('utf8') - public_key = key.publickey().export_key().decode('utf8') + private_key, public_key = create_key_pair() self.fake_remote = Sender( 'http://localhost/user/remote', @@ -76,7 +70,8 @@ class Signature(TestCase): now = http_date() sender = self.fake_remote signature = make_signature(sender, self.rat.inbox, now) - assert self.send_follow(sender, signature, now).status_code == 200 + response = self.send_follow(sender, signature, now) + self.assertEqual(response.status_code, 200) @responses.activate def test_nonexistent_signer(self): @@ -89,4 +84,5 @@ class Signature(TestCase): now = http_date() sender = self.fake_remote signature = make_signature(sender, self.rat.inbox, now) - assert self.send_follow(sender, signature, now).status_code == 401 + response = self.send_follow(sender, signature, now) + self.assertEqual(response.status_code, 401) diff --git a/fr-dev b/fr-dev index e2f21a8b..8fad19dd 100755 --- a/fr-dev +++ b/fr-dev @@ -38,7 +38,8 @@ case "$1" in docker-compose restart celery_worker ;; test) - docker-compose exec web coverage run --source='.' manage.py test + shift 1 + docker-compose exec web coverage run --source='.' manage.py test "$@" ;; test_report) docker-compose exec web coverage report