diff --git a/fedireads/broadcast.py b/fedireads/broadcast.py index 05b6904ec..53da0c29d 100644 --- a/fedireads/broadcast.py +++ b/fedireads/broadcast.py @@ -5,7 +5,7 @@ import requests from fedireads import models from fedireads.tasks import app -from fedireads.signatures import make_signature +from fedireads.signatures import make_signature, make_digest def get_public_recipients(user, software=None): @@ -67,12 +67,16 @@ def sign_and_send(sender, activity, destination): # this shouldn't happen. it would be bad if it happened. raise ValueError('No private key found for sender') + data = json.dumps(activity).encode('utf-8') + digest = make_digest(data) + response = requests.post( destination, - data=json.dumps(activity), + data=data, headers={ 'Date': now, - 'Signature': make_signature(sender, destination, now), + 'Digest': digest, + 'Signature': make_signature(sender, destination, now, digest), 'Content-Type': 'application/activity+json; charset=utf-8', }, ) diff --git a/fedireads/remote_user.py b/fedireads/remote_user.py index 1a3a65e1c..81c8c34c1 100644 --- a/fedireads/remote_user.py +++ b/fedireads/remote_user.py @@ -114,6 +114,10 @@ def get_or_create_remote_server(domain): 'https://%s/.well-known/nodeinfo' % domain, headers={'Accept': 'application/activity+json'} ) + + if response.status_code != 200: + return None + data = response.json() try: nodeinfo_url = data.get('links')[0].get('href') diff --git a/fedireads/signatures.py b/fedireads/signatures.py index d9f1a088e..49d1a2d37 100644 --- a/fedireads/signatures.py +++ b/fedireads/signatures.py @@ -1,4 +1,6 @@ +import hashlib from urllib.parse import urlparse +import datetime from base64 import b64encode, b64decode from Crypto import Random @@ -6,6 +8,7 @@ from Crypto.PublicKey import RSA from Crypto.Signature import pkcs1_15 #pylint: disable=no-name-in-module from Crypto.Hash import SHA256 +MAX_SIGNATURE_AGE = 300 def create_key_pair(): random_generator = Random.new().read @@ -16,12 +19,13 @@ def create_key_pair(): return private_key, public_key -def make_signature(sender, destination, date): +def make_signature(sender, destination, date, digest): inbox_parts = urlparse(destination) signature_headers = [ '(request-target): post %s' % inbox_parts.path, 'host: %s' % inbox_parts.netloc, 'date: %s' % date, + 'digest: %s' % digest, ] message_to_sign = '\n'.join(signature_headers) signer = pkcs1_15.new(RSA.import_key(sender.private_key)) @@ -29,11 +33,26 @@ def make_signature(sender, destination, date): signature = { 'keyId': '%s#main-key' % sender.remote_id, 'algorithm': 'rsa-sha256', - 'headers': '(request-target) host date', + 'headers': '(request-target) host date digest', 'signature': b64encode(signed_message).decode('utf8'), } return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items()) +def make_digest(data): + return 'SHA-256=' + b64encode(hashlib.sha512(data).digest()).decode('utf-8') + +def verify_digest(request): + algorithm, digest = request.headers['digest'].split('=', 1) + if algorithm == 'SHA-256': + hash_function = hashlib.sha256 + elif algorithm == 'SHA-512': + hash_function = hashlib.sha512 + else: + raise ValueError("Unsupported hash function: {}".format(algorithm)) + + expected = hash_function(request.body).digest() + if b64decode(digest) != expected: + return ValueError("Invalid HTTP Digest header") class Signature: def __init__(self, key_id, headers, signature): @@ -60,6 +79,9 @@ class Signature: def verify(self, public_key, request): ''' verify rsa signature ''' + if http_date_age(request.headers['date']) > MAX_SIGNATURE_AGE: + raise ValueError( + "Request too old: %s" % (request.headers['date'],)) public_key = RSA.import_key(public_key) comparison_string = [] @@ -68,6 +90,8 @@ class Signature: comparison_string.append( '(request-target): post %s' % request.path) else: + if signed_header_name == 'digest': + verify_digest(request) comparison_string.append('%s: %s' % ( signed_header_name, request.headers[signed_header_name] @@ -80,3 +104,8 @@ class Signature: # raises a ValueError if it fails signer.verify(digest, self.signature) + +def http_date_age(datestr): + parsed = datetime.datetime.strptime(datestr, '%a, %d %b %Y %H:%M:%S GMT') + delta = datetime.datetime.utcnow() - parsed + return delta.total_seconds() diff --git a/fedireads/tests/test_signing.py b/fedireads/tests/test_signing.py index 9cfa1311c..b93d263e9 100644 --- a/fedireads/tests/test_signing.py +++ b/fedireads/tests/test_signing.py @@ -1,6 +1,8 @@ +import time from collections import namedtuple from urllib.parse import urlsplit +import json import responses from django.test import TestCase, Client @@ -9,7 +11,10 @@ from django.utils.http import http_date from fedireads.models import User from fedireads.activitypub import get_follow_request from fedireads.settings import DOMAIN -from fedireads.signatures import create_key_pair, make_signature +from fedireads.signatures import create_key_pair, make_signature, make_digest + +def get_follow_data(follower, followee): + return json.dumps(get_follow_request(follower, followee)).encode('utf-8') Sender = namedtuple('Sender', ('remote_id', 'private_key', 'public_key')) @@ -27,35 +32,44 @@ class Signature(TestCase): public_key, ) - def send_follow(self, sender, signature, now): + def send(self, signature, now, data): c = Client() return c.post( urlsplit(self.rat.inbox).path, - data=get_follow_request( - sender, - self.rat, - ), + data=data, content_type='application/json', **{ 'HTTP_DATE': now, 'HTTP_SIGNATURE': signature, + 'HTTP_DIGEST': make_digest(data), 'HTTP_CONTENT_TYPE': 'application/activity+json; charset=utf-8', 'HTTP_HOST': DOMAIN, } ) + def send_test_request( + self, + sender, + signer=None, + send_data=None, + digest=None, + date=None): + now = date or http_date() + data = get_follow_data(sender, self.rat) + signature = make_signature( + signer or sender, self.rat.inbox, now, digest or make_digest(data)) + return self.send(signature, now, send_data or data) + def test_correct_signature(self): - now = http_date() - signature = make_signature(self.mouse, self.rat.inbox, now) - return self.send_follow(self.mouse, signature, now).status_code == 200 + response = self.send_test_request(sender=self.mouse) + self.assertEqual(response.status_code, 200) def test_wrong_signature(self): ''' Messages must be signed by the right actor. (cat cannot sign messages on behalf of mouse) ''' - now = http_date() - signature = make_signature(self.cat, self.rat.inbox, now) - assert self.send_follow(self.mouse, signature, now).status_code == 401 + response = self.send_test_request(sender=self.mouse, signer=self.cat) + self.assertEqual(response.status_code, 401) @responses.activate def test_remote_signer(self): @@ -67,10 +81,7 @@ class Signature(TestCase): }}, status=200) - now = http_date() - sender = self.fake_remote - signature = make_signature(sender, self.rat.inbox, now) - response = self.send_follow(sender, signature, now) + response = self.send_test_request(sender=self.fake_remote) self.assertEqual(response.status_code, 200) @responses.activate @@ -81,8 +92,26 @@ class Signature(TestCase): json={'error': 'not found'}, status=404) - now = http_date() - sender = self.fake_remote - signature = make_signature(sender, self.rat.inbox, now) - response = self.send_follow(sender, signature, now) + response = self.send_test_request(sender=self.fake_remote) + self.assertEqual(response.status_code, 401) + + def test_changed_data(self): + '''Message data must match the digest header.''' + response = self.send_test_request( + self.mouse, + send_data=get_follow_data(self.mouse, self.cat)) + self.assertEqual(response.status_code, 401) + + def test_invalid_digest(self): + response = self.send_test_request( + self.mouse, + digest='SHA-256=AAAAAAAAAAAAAAAAAA') + self.assertEqual(response.status_code, 401) + + def test_old_message(self): + '''Old messages should be rejected to prevent replay attacks.''' + response = self.send_test_request( + self.mouse, + date=http_date(time.time() - 301) + ) self.assertEqual(response.status_code, 401)