Merge pull request #176 from cthulahoops/digests

Verify http digest is correct if specified in signature.
This commit is contained in:
Mouse Reeve 2020-05-20 08:17:00 -07:00 committed by GitHub
commit 00bd8928b0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 91 additions and 25 deletions

View File

@ -5,7 +5,7 @@ import requests
from fedireads import models from fedireads import models
from fedireads.tasks import app 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): 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. # this shouldn't happen. it would be bad if it happened.
raise ValueError('No private key found for sender') raise ValueError('No private key found for sender')
data = json.dumps(activity).encode('utf-8')
digest = make_digest(data)
response = requests.post( response = requests.post(
destination, destination,
data=json.dumps(activity), data=data,
headers={ headers={
'Date': now, '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', 'Content-Type': 'application/activity+json; charset=utf-8',
}, },
) )

View File

@ -114,6 +114,10 @@ def get_or_create_remote_server(domain):
'https://%s/.well-known/nodeinfo' % domain, 'https://%s/.well-known/nodeinfo' % domain,
headers={'Accept': 'application/activity+json'} headers={'Accept': 'application/activity+json'}
) )
if response.status_code != 200:
return None
data = response.json() data = response.json()
try: try:
nodeinfo_url = data.get('links')[0].get('href') nodeinfo_url = data.get('links')[0].get('href')

View File

@ -1,4 +1,6 @@
import hashlib
from urllib.parse import urlparse from urllib.parse import urlparse
import datetime
from base64 import b64encode, b64decode from base64 import b64encode, b64decode
from Crypto import Random 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.Signature import pkcs1_15 #pylint: disable=no-name-in-module
from Crypto.Hash import SHA256 from Crypto.Hash import SHA256
MAX_SIGNATURE_AGE = 300
def create_key_pair(): def create_key_pair():
random_generator = Random.new().read random_generator = Random.new().read
@ -16,12 +19,13 @@ def create_key_pair():
return private_key, public_key return private_key, public_key
def make_signature(sender, destination, date): def make_signature(sender, destination, date, digest):
inbox_parts = urlparse(destination) inbox_parts = urlparse(destination)
signature_headers = [ signature_headers = [
'(request-target): post %s' % inbox_parts.path, '(request-target): post %s' % inbox_parts.path,
'host: %s' % inbox_parts.netloc, 'host: %s' % inbox_parts.netloc,
'date: %s' % date, 'date: %s' % date,
'digest: %s' % digest,
] ]
message_to_sign = '\n'.join(signature_headers) message_to_sign = '\n'.join(signature_headers)
signer = pkcs1_15.new(RSA.import_key(sender.private_key)) signer = pkcs1_15.new(RSA.import_key(sender.private_key))
@ -29,11 +33,26 @@ def make_signature(sender, destination, date):
signature = { signature = {
'keyId': '%s#main-key' % sender.remote_id, 'keyId': '%s#main-key' % sender.remote_id,
'algorithm': 'rsa-sha256', 'algorithm': 'rsa-sha256',
'headers': '(request-target) host date', 'headers': '(request-target) host date digest',
'signature': b64encode(signed_message).decode('utf8'), 'signature': b64encode(signed_message).decode('utf8'),
} }
return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items()) 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: class Signature:
def __init__(self, key_id, headers, signature): def __init__(self, key_id, headers, signature):
@ -60,6 +79,9 @@ class Signature:
def verify(self, public_key, request): def verify(self, public_key, request):
''' verify rsa signature ''' ''' 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) public_key = RSA.import_key(public_key)
comparison_string = [] comparison_string = []
@ -68,6 +90,8 @@ class Signature:
comparison_string.append( comparison_string.append(
'(request-target): post %s' % request.path) '(request-target): post %s' % request.path)
else: else:
if signed_header_name == 'digest':
verify_digest(request)
comparison_string.append('%s: %s' % ( comparison_string.append('%s: %s' % (
signed_header_name, signed_header_name,
request.headers[signed_header_name] request.headers[signed_header_name]
@ -80,3 +104,8 @@ class Signature:
# raises a ValueError if it fails # raises a ValueError if it fails
signer.verify(digest, self.signature) 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()

View File

@ -1,6 +1,8 @@
import time
from collections import namedtuple from collections import namedtuple
from urllib.parse import urlsplit from urllib.parse import urlsplit
import json
import responses import responses
from django.test import TestCase, Client from django.test import TestCase, Client
@ -9,7 +11,10 @@ from django.utils.http import http_date
from fedireads.models import User from fedireads.models import User
from fedireads.activitypub import get_follow_request from fedireads.activitypub import get_follow_request
from fedireads.settings import DOMAIN 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')) Sender = namedtuple('Sender', ('remote_id', 'private_key', 'public_key'))
@ -27,35 +32,44 @@ class Signature(TestCase):
public_key, public_key,
) )
def send_follow(self, sender, signature, now): def send(self, signature, now, data):
c = Client() c = Client()
return c.post( return c.post(
urlsplit(self.rat.inbox).path, urlsplit(self.rat.inbox).path,
data=get_follow_request( data=data,
sender,
self.rat,
),
content_type='application/json', content_type='application/json',
**{ **{
'HTTP_DATE': now, 'HTTP_DATE': now,
'HTTP_SIGNATURE': signature, 'HTTP_SIGNATURE': signature,
'HTTP_DIGEST': make_digest(data),
'HTTP_CONTENT_TYPE': 'application/activity+json; charset=utf-8', 'HTTP_CONTENT_TYPE': 'application/activity+json; charset=utf-8',
'HTTP_HOST': DOMAIN, '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): def test_correct_signature(self):
now = http_date() response = self.send_test_request(sender=self.mouse)
signature = make_signature(self.mouse, self.rat.inbox, now) self.assertEqual(response.status_code, 200)
return self.send_follow(self.mouse, signature, now).status_code == 200
def test_wrong_signature(self): def test_wrong_signature(self):
''' Messages must be signed by the right actor. ''' Messages must be signed by the right actor.
(cat cannot sign messages on behalf of mouse) (cat cannot sign messages on behalf of mouse)
''' '''
now = http_date() response = self.send_test_request(sender=self.mouse, signer=self.cat)
signature = make_signature(self.cat, self.rat.inbox, now) self.assertEqual(response.status_code, 401)
assert self.send_follow(self.mouse, signature, now).status_code == 401
@responses.activate @responses.activate
def test_remote_signer(self): def test_remote_signer(self):
@ -67,10 +81,7 @@ class Signature(TestCase):
}}, }},
status=200) status=200)
now = http_date() response = self.send_test_request(sender=self.fake_remote)
sender = self.fake_remote
signature = make_signature(sender, self.rat.inbox, now)
response = self.send_follow(sender, signature, now)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@responses.activate @responses.activate
@ -81,8 +92,26 @@ class Signature(TestCase):
json={'error': 'not found'}, json={'error': 'not found'},
status=404) status=404)
now = http_date() response = self.send_test_request(sender=self.fake_remote)
sender = self.fake_remote self.assertEqual(response.status_code, 401)
signature = make_signature(sender, self.rat.inbox, now)
response = self.send_follow(sender, signature, now) 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) self.assertEqual(response.status_code, 401)