Skip to content

Commit

Permalink
AP: verify incoming signatures!
Browse files Browse the repository at this point in the history
for #315
  • Loading branch information
snarfed committed Feb 15, 2023
1 parent ebcc3a0 commit 63d0e59
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 22 deletions.
39 changes: 32 additions & 7 deletions activitypub.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Handles requests for ActivityPub endpoints: actors, inbox, etc.
"""
from base64 import b64encode
import datetime
from hashlib import sha256
import logging
import re
import threading
Expand All @@ -10,6 +12,7 @@
from google.cloud import ndb
from google.cloud.ndb import OR
from granary import as1, as2
from httpsig import HeaderVerifier
from oauth_dropins.webutil import flask_util, util
from oauth_dropins.webutil.flask_util import error
from oauth_dropins.webutil.util import json_dumps, json_loads
Expand Down Expand Up @@ -133,14 +136,41 @@ def inbox(domain=None):
if type not in SUPPORTED_TYPES:
error(f'Sorry, {type} activities are not supported yet.', status=501)

# TODO: verify signature if there is one

# load user
user = None
if domain:
user = User.get_by_id(domain)
if not user:
return f'User {domain} not found', 404

# load actor
if actor and isinstance(actor, str):
actor = activity['actor'] = \
json_loads(common.get_object(actor, user=user).as2)

# optionally verify signature
# TODO: switch this from erroring to logging lots of detail. need to see
# which headers, key shapes, etc we get in the wild.
if request.headers.get('Signature'):
digest = request.headers.get('Digest')
if not digest:
error(f'Missing Digest header, required for HTTP Signature', status=401)

expected = b64encode(sha256(request.data).digest()).decode()
if digest.removeprefix('SHA-256=') != expected:
error(f'Invalid Digest header, required for HTTP Signature', status=401)

# TODO: check keyId
key = actor.get('publicKey', {}).get('publicKeyPem', {})
try:
if not HeaderVerifier(request.headers, key, method='GET', path=request.path,
required_headers=common.HTTP_SIG_HEADERS,
sign_header='signature').verify():
error(f'HTTP Signature verification failed', status=401)
except BaseException as e:
error(f'HTTP Signature verification failed: {e}', status=401)

# handle activity!
if type == 'Undo' and obj_as2.get('type') == 'Follow':
# skip actor fetch below; we don't need it to undo a follow
undo_follow(redirect_unwrap(activity))
Expand Down Expand Up @@ -182,11 +212,6 @@ def inbox(domain=None):
ndb.put_multi(followers)
return 'OK'

# fetch actor if necessary so we have name, profile photo, etc
if actor and isinstance(actor, str):
actor = activity['actor'] = \
json_loads(common.get_object(actor, user=user).as2)

# fetch object if necessary so we can render it in feeds
if type in FETCH_OBJECT_TYPES and isinstance(activity.get('object'), str):
obj_as2 = activity['object'] = \
Expand Down
9 changes: 5 additions & 4 deletions common.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@
CACHE_TIME = timedelta(seconds=60)
PAGE_SIZE = 20

HTTP_SIG_HEADERS = ('Date', 'Host', 'Digest', '(request-target)')


def host_url(path_query=None):
base = request.host_url
Expand Down Expand Up @@ -202,10 +204,9 @@ def signed_request(fn, url, user, data=None, log_data=True, headers=None, **kwar
# implementations require, eg Peertube.
# https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12#section-2.3
# https://github.com/snarfed/bridgy-fed/issues/40
auth = HTTPSignatureAuth(
secret=user.private_pem(), key_id=key_id, algorithm='rsa-sha256',
sign_header='signature',
headers=('Date', 'Host', 'Digest', '(request-target)'))
auth = HTTPSignatureAuth(secret=user.private_pem(), key_id=key_id,
algorithm='rsa-sha256', sign_header='signature',
headers=HTTP_SIG_HEADERS)

# make HTTP request
kwargs.setdefault('gateway', True)
Expand Down
92 changes: 82 additions & 10 deletions tests/test_activitypub.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# coding=utf-8
"""Unit tests for activitypub.py."""
from base64 import b64encode
import copy
from datetime import datetime, timedelta
from hashlib import sha256
from unittest.mock import ANY, call, patch

from google.cloud import ndb
from granary import as2
from httpsig import HeaderSigner, HeaderVerifier
from oauth_dropins.webutil import util
from oauth_dropins.webutil.testutil import requests_response
from oauth_dropins.webutil.util import json_dumps, json_loads
Expand All @@ -13,7 +17,7 @@

import activitypub
import common
from models import Follower, Object, Target, User
from models import Follower, Object, User
from . import testutil

ACTOR = {
Expand Down Expand Up @@ -194,7 +198,6 @@ def setUp(self):
super().setUp()
self.user = User.get_or_create('foo.com', has_hcard=True,
actor_as2=json_dumps(ACTOR))
activitypub.seen_ids.clear()

def test_actor(self, *_):
got = self.client.get('/foo.com')
Expand Down Expand Up @@ -223,7 +226,7 @@ def test_actor(self, *_):
'publicKey': {
'id': 'http://localhost/foo.com',
'owner': 'http://localhost/foo.com',
'publicKeyPem': User.get_by_id('foo.com').public_pem().decode(),
'publicKeyPem': self.user.public_pem().decode(),
},
}, got.json)

Expand Down Expand Up @@ -598,6 +601,9 @@ def test_inbox_follow_use_instead_strip_www(self, mock_head, mock_get, mock_post

def test_inbox_undo_follow(self, mock_head, mock_get, mock_post):
mock_head.return_value = requests_response(url='https://foo.com/')
mock_get.side_effect = [
self.as2_resp(ACTOR),
]

Follower.get_or_create('foo.com', ACTOR['id'])

Expand Down Expand Up @@ -629,19 +635,30 @@ def test_inbox_follow_inactive(self, mock_head, mock_get, mock_post):

def test_inbox_undo_follow_doesnt_exist(self, mock_head, mock_get, mock_post):
mock_head.return_value = requests_response(url='https://foo.com/')
mock_get.side_effect = [
self.as2_resp(ACTOR),
]

got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED)
self.assertEqual(200, got.status_code)

def test_inbox_undo_follow_inactive(self, mock_head, mock_get, mock_post):
mock_head.return_value = requests_response(url='https://foo.com/')
mock_get.side_effect = [
self.as2_resp(ACTOR),
]

Follower.get_or_create('foo.com', ACTOR['id'], status='inactive')

got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED)
self.assertEqual(200, got.status_code)

def test_inbox_undo_follow_composite_object(self, mock_head, mock_get, mock_post):
mock_head.return_value = requests_response(url='https://foo.com/')
mock_get.side_effect = [
self.as2_resp(ACTOR),
]

Follower.get_or_create('foo.com', ACTOR['id'], status='inactive')

undo_follow = copy.deepcopy(UNDO_FOLLOW_WRAPPED)
Expand Down Expand Up @@ -683,23 +700,74 @@ def test_inbox_bad_object_url(self, mock_head, mock_get, mock_post):

self.assertIsNone(Object.get_by_id(bad_url))

def test_delete_actor(self, _, __, ___):
def test_inbox_verify_http_signature(self, _, mock_get, ___):
# actor with a public key
mock_get.return_value = self.as2_resp({
**ACTOR,
'publicKey': {
'id': 'my-key-id',
'owner': 'http://sen/der',
'publicKeyPem': self.user.public_pem().decode(),
},
})

# manually construct signature
body = json_dumps(NOTE)
digest = b64encode(sha256(body.encode()).digest()).decode()
headers = {
'Date': 'Sun, 02 Jan 2022 03:04:05 GMT',
'Host': 'localhost',
'Content-Type': as2.CONTENT_TYPE,
'Digest': f'SHA-256={digest}',
}
hs = HeaderSigner('my-key-id', self.user.private_pem().decode(),
algorithm='rsa-sha256', sign_header='signature',
headers=('Date', 'Host', 'Digest', '(request-target)'))
headers = hs.sign(headers, method='GET', path='/inbox')

# valid signature
resp = self.client.post('/inbox', data=body, headers=headers)
self.assertEqual(200, resp.status_code)

# invalid signature, content changed
activitypub.seen_ids.clear()
obj_key = ndb.Key(Object, NOTE['id'])
obj_key.delete()
resp = self.client.post('/inbox', json={**NOTE, 'content': 'z'}, headers=headers)
self.assertEqual(401, resp.status_code)

# invalid signature, header changed
activitypub.seen_ids.clear()
obj_key.delete()
orig_date = headers['Date']
resp = self.client.post('/inbox', data=body, headers={**headers, 'Date': 'X'})
self.assertEqual(401, resp.status_code)

def test_delete_actor(self, _, mock_get, ___):
follower = Follower.get_or_create('foo.com', DELETE['actor'])
followee = Follower.get_or_create(DELETE['actor'], 'snarfed.org')
# other unrelated follower
other = Follower.get_or_create('foo.com', 'https://mas.to/users/other')
self.assertEqual(3, Follower.query().count())

mock_get.side_effect = [
self.as2_resp(ACTOR),
]

got = self.client.post('/inbox', json=DELETE)
self.assertEqual(200, got.status_code)
self.assertEqual('inactive', follower.key.get().status)
self.assertEqual('inactive', followee.key.get().status)
self.assertEqual('active', other.key.get().status)

def test_delete_note(self, _, __, ___):
def test_delete_note(self, _, mock_get, ___):
obj = Object(id='http://an/obj', as1='{}')
obj.put()

mock_get.side_effect = [
self.as2_resp(ACTOR),
]

delete = {
**DELETE,
'object': 'http://an/obj',
Expand All @@ -714,14 +782,18 @@ def test_delete_note(self, _, __, ___):
obj.deleted = True
self.assert_entities_equal(obj, common.get_object.cache['http://an/obj'])

def test_update_note(self, *_):
def test_update_note(self, *mocks):
Object(id='https://a/note', as1='{}').put()
self._test_update()
self._test_update(*mocks)

def test_update_unknown(self, *mocks):
self._test_update(*mocks)

def test_update_unknown(self, *_):
self._test_update()
def _test_update(self, _, mock_get, ___):
mock_get.side_effect = [
self.as2_resp(ACTOR),
]

def _test_update(self):
resp = self.client.post('/inbox', json=UPDATE_NOTE)
self.assertEqual(200, resp.status_code)

Expand Down
3 changes: 2 additions & 1 deletion tests/testutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import requests

from app import app, cache
import common
import activitypub, common
from models import Object, Target


Expand All @@ -29,6 +29,7 @@ def setUp(self):
super().setUp()
app.testing = True
cache.clear()
activitypub.seen_ids.clear()
common.get_object.cache.clear()

self.client = app.test_client()
Expand Down

0 comments on commit 63d0e59

Please sign in to comment.