Fixes to address the reviewer's comments. Where appropriate, align with existing code structure.

ld-signatures
Alain St-Denis 2023-03-26 14:47:40 -04:00
rodzic 26f93ec1be
commit e7d954b788
9 zmienionych plików z 81 dodań i 58 usunięć

Wyświetl plik

@ -2,10 +2,12 @@ import json
from datetime import timedelta from datetime import timedelta
from pyld import jsonld from pyld import jsonld
from federation.utils.django import get_redis try:
from federation.utils.django import get_redis
cache = get_redis() or {} cache = get_redis() or {}
EXPIRATION = int(timedelta(weeks=4).total_seconds()) EXPIRATION = int(timedelta(weeks=4).total_seconds())
except:
cache = {}
# This is required to workaround a bug in pyld that has the Accept header # This is required to workaround a bug in pyld that has the Accept header
@ -24,9 +26,9 @@ def get_loader(*args, **kwargs):
options['headers']['Accept'] = 'application/ld+json' options['headers']['Accept'] = 'application/ld+json'
doc = requests_loader(url, options) doc = requests_loader(url, options)
if isinstance(cache, dict): if isinstance(cache, dict):
cache[url] = json.dumps(doc) cache[key] = json.dumps(doc)
else: else:
cache.set(f'ld_cache:{url}', json.dumps(doc), ex=EXPIRATION) cache.set(key, json.dumps(doc), ex=EXPIRATION)
return doc return doc
return loader return loader

Wyświetl plik

@ -6,6 +6,7 @@ from pyld import jsonld
from federation.entities.activitypub.constants import CONTEXT_ACTIVITYSTREAMS, CONTEXT_SECURITY, NAMESPACE_PUBLIC from federation.entities.activitypub.constants import CONTEXT_ACTIVITYSTREAMS, CONTEXT_SECURITY, NAMESPACE_PUBLIC
# Extract context information from the metadata parameter defined for fields # Extract context information from the metadata parameter defined for fields
# that are not part of the official AP spec. Use the same extended context for # that are not part of the official AP spec. Use the same extended context for
# inbound payload. For outbound payload, build a context with only the required # inbound payload. For outbound payload, build a context with only the required
@ -22,16 +23,16 @@ class LdContextManager:
self._extensions[klass] = {} self._extensions[klass] = {}
ctx = getattr(klass, 'ctx', []) ctx = getattr(klass, 'ctx', [])
if ctx: if ctx:
self._extensions[klass].update({klass.__name__:ctx}) self._extensions[klass].update({klass.__name__: ctx})
for name, value in klass.schema().declared_fields.items(): for name, value in klass.schema().declared_fields.items():
ctx = value.metadata.get('ctx') or [] ctx = value.metadata.get('ctx') or []
if ctx: if ctx:
self._extensions[klass].update({name:ctx}) self._extensions[klass].update({name: ctx})
merged = {} merged = {}
for field in self._extensions.values(): for field in self._extensions.values():
for ctx in field.values(): for ctx in field.values():
self._add_extensions(ctx, self._named, merged) self._add_extensions(ctx, self._named, merged)
self._merged = copy.copy(self._named) self._merged = copy.copy(self._named)
self._merged.append(merged) self._merged.append(merged)
def _add_extensions(self, field, named, extensions): def _add_extensions(self, field, named, extensions):
@ -41,7 +42,6 @@ class LdContextManager:
elif isinstance(item, dict): elif isinstance(item, dict):
extensions.update(item) extensions.update(item)
def _get_fields(self, obj): def _get_fields(self, obj):
for klass in self._extensions.keys(): for klass in self._extensions.keys():
if issubclass(type(obj), klass): if issubclass(type(obj), klass):
@ -58,14 +58,16 @@ class LdContextManager:
def patch_payload(payload, patched): def patch_payload(payload, patched):
for field in ('attachment', 'cc', 'tag', 'to'): for field in ('attachment', 'cc', 'tag', 'to'):
value = payload.get(field) value = payload.get(field)
if value and not isinstance(value, list): if not value:
continue
if not isinstance(value, list):
value = [value] value = [value]
patched[field] = value patched[field] = value
if field in ('cc', 'to'): if field in ('cc', 'to'):
try: try:
idx = value.index('as:Public') idx = value.index('as:Public')
patched[field][idx] = value[idx].replace('as:Public', NAMESPACE_PUBLIC) patched[field][idx] = value[idx].replace('as:Public', NAMESPACE_PUBLIC)
except: except ValueError:
pass pass
if isinstance(payload.get('object'), dict): if isinstance(payload.get('object'), dict):
patch_payload(payload['object'], patched['object']) patch_payload(payload['object'], patched['object'])
@ -88,22 +90,25 @@ class LdContextManager:
if field in to_add.keys(): if field in to_add.keys():
if field_value is not missing or obj.signable and field == 'signature': if field_value is not missing or obj.signable and field == 'signature':
self._add_extensions(to_add[field], final, extensions) self._add_extensions(to_add[field], final, extensions)
if not isinstance(field_value, list): field_value = [field_value] if not isinstance(field_value, list):
field_value = [field_value]
for value in field_value: for value in field_value:
if issubclass(type(value), (Object, Link)): if issubclass(type(value), (Object, Link)):
walk_object(value) walk_object(value)
walk_object(obj) walk_object(obj)
if extensions: final.append(extensions) if extensions:
final.append(extensions)
# compact the array if len == 1 to minimize test changes # compact the array if len == 1 to minimize test changes
return final if len(final) > 1 else final[0] return final if len(final) > 1 else final[0]
def merge_context(self, ctx): def merge_context(self, ctx):
# One platform sends a single string context # One platform sends a single string context
if isinstance(ctx, str): ctx = [ctx] if isinstance(ctx, str):
ctx = [ctx]
# add a # at the end of the python-federation string # add a # at the end of the python-federation string
# for socialhome payloads # for legacy socialhome payloads
s = json.dumps(ctx) s = json.dumps(ctx)
if 'python-federation"' in s: if 'python-federation"' in s:
ctx = json.loads(s.replace('python-federation', 'python-federation#', 1)) ctx = json.loads(s.replace('python-federation', 'python-federation#', 1))
@ -112,7 +117,7 @@ class LdContextManager:
# is not a json-ld document. # is not a json-ld document.
try: try:
ctx.pop(ctx.index('http://joinmastodon.org/ns')) ctx.pop(ctx.index('http://joinmastodon.org/ns'))
except: except ValueError:
pass pass
# remove @language in context since this directive is not # remove @language in context since this directive is not
@ -124,13 +129,12 @@ class LdContextManager:
for i, v in enumerate(ctx): for i, v in enumerate(ctx):
if isinstance(v, dict): if isinstance(v, dict):
v.pop('@language', None) v.pop('@language', None)
if len(v) == 0: idx.insert(0, i) if len(v) == 0:
for i in idx: ctx.pop(i) idx.insert(0, i)
for i in idx:
ctx.pop(i)
# AP activities may be signed, but most platforms don't # Merge all defined AP extensions to the inbound context
# define RsaSignature2017. add it to the context
# hubzilla doesn't define the discoverable property in its context
# include all Mastodon extensions for platforms that only define http://joinmastodon.org/ns in their context
uris = [] uris = []
defs = {} defs = {}
# Merge original context dicts in one dict # Merge original context dicts in one dict

Wyświetl plik

@ -3,7 +3,7 @@ import logging
import math import math
import re import re
from base64 import b64encode, b64decode from base64 import b64encode, b64decode
from copy import copy from copy import copy
from funcy import omit from funcy import omit
from pyld import jsonld from pyld import jsonld
@ -22,13 +22,13 @@ def create_ld_signature(obj, author):
sig = { sig = {
'created': datetime.datetime.now(tz=datetime.timezone.utc).isoformat(timespec='seconds'), 'created': datetime.datetime.now(tz=datetime.timezone.utc).isoformat(timespec='seconds'),
'creator': f'{author.id}#main-key', 'creator': f'{author.id}#main-key',
'@context':'https://w3id.org/security/v1' '@context': 'https://w3id.org/security/v1'
} }
try: try:
private_key = import_key(author.private_key) private_key = import_key(author.private_key)
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
logger.warning(f'ld_signature - {exc}') logger.warning('ld_signature - %s', exc)
return None return None
signer = pkcs1_15.new(private_key) signer = pkcs1_15.new(private_key)
@ -40,7 +40,7 @@ def create_ld_signature(obj, author):
sig.update({'type': 'RsaSignature2017', 'signatureValue': b64encode(signature).decode()}) sig.update({'type': 'RsaSignature2017', 'signatureValue': b64encode(signature).decode()})
sig.pop('@context') sig.pop('@context')
obj.update({'signature':sig}) obj.update({'signature': sig})
def verify_ld_signature(payload): def verify_ld_signature(payload):
@ -49,24 +49,25 @@ def verify_ld_signature(payload):
""" """
signature = copy(payload.get('signature', None)) signature = copy(payload.get('signature', None))
if not signature: if not signature:
logger.warning(f'ld_signature - No signature in {payload.get("id", "the payload")}') logger.warning('ld_signature - No signature in %s', payload.get("id", "the payload"))
return None return None
# retrieve the author's public key # retrieve the author's public key
profile = retrieve_and_parse_document(signature.get('creator')) profile = retrieve_and_parse_document(signature.get('creator'))
if not profile: if not profile:
logger.warning(f'ld_signature - Failed to retrieve profile for {signature.get("creator")}')
logger.warning('ld_signature - Failed to retrieve profile for %s', signature.get("creator"))
return None return None
try: try:
pkey = import_key(profile.public_key) pkey = import_key(profile.public_key)
except ValueError as exc: except ValueError as exc:
logger.warning(f'ld_signature - {exc}') logger.warning('ld_signature - %s', exc)
return None return None
verifier = pkcs1_15.new(pkey) verifier = pkcs1_15.new(pkey)
# Compute digests and verify signature # Compute digests and verify signature
sig = omit(signature, ('type', 'signatureValue')) sig = omit(signature, ('type', 'signatureValue'))
sig.update({'@context':'https://w3id.org/security/v1'}) sig.update({'@context': 'https://w3id.org/security/v1'})
sig_digest = hash(sig) sig_digest = hash(sig)
obj = omit(payload, 'signature') obj = omit(payload, 'signature')
obj_digest = hash(obj) obj_digest = hash(obj)
@ -75,15 +76,15 @@ def verify_ld_signature(payload):
sig_value = b64decode(signature.get('signatureValue')) sig_value = b64decode(signature.get('signatureValue'))
try: try:
verifier.verify(SHA256.new(digest), sig_value) verifier.verify(SHA256.new(digest), sig_value)
logger.debug(f'ld_signature - {payload.get("id")} has a valid signature') logger.debug('ld_signature - %s has a valid signature', payload.get("id"))
return profile.id return profile.id
except ValueError: except ValueError:
logger.warning(f'ld_signature - Invalid signature for {payload.get("id")}') logger.warning('ld_signature - Invalid signature for %s', payload.get("id"))
return None return None
def hash(obj): def hash(obj):
nquads = NormalizedDoubles().normalize(obj, options={'format':'application/nquads','algorithm':'URDNA2015'}) nquads = NormalizedDoubles().normalize(obj, options={'format': 'application/nquads', 'algorithm': 'URDNA2015'})
return SHA256.new(nquads.encode('utf-8')).hexdigest() return SHA256.new(nquads.encode('utf-8')).hexdigest()

Wyświetl plik

@ -10,6 +10,7 @@ import bleach
from calamus import fields from calamus import fields
from calamus.schema import JsonLDAnnotation, JsonLDSchema, JsonLDSchemaOpts from calamus.schema import JsonLDAnnotation, JsonLDSchema, JsonLDSchemaOpts
from calamus.utils import normalize_value from calamus.utils import normalize_value
from cryptography.exceptions import InvalidSignature
from marshmallow import exceptions, pre_load, post_load, post_dump from marshmallow import exceptions, pre_load, post_load, post_dump
from marshmallow.fields import Integer from marshmallow.fields import Integer
from marshmallow.utils import EXCLUDE, missing from marshmallow.utils import EXCLUDE, missing
@ -25,7 +26,6 @@ from federation.outbound import handle_send
from federation.types import UserType, ReceiverVariant from federation.types import UserType, ReceiverVariant
from federation.utils.activitypub import retrieve_and_parse_document, retrieve_and_parse_profile, \ from federation.utils.activitypub import retrieve_and_parse_document, retrieve_and_parse_profile, \
get_profile_id_from_webfinger get_profile_id_from_webfinger
from federation.utils.django import get_configuration
from federation.utils.text import with_slash, validate_handle from federation.utils.text import with_slash, validate_handle
logger = logging.getLogger("federation") logger = logging.getLogger("federation")
@ -297,12 +297,23 @@ class Object(BaseEntity, metaclass=JsonLDAnnotation):
# TODO: rework validation # TODO: rework validation
def validate(self, direction='inbound'): def validate(self, direction='inbound'):
if direction == 'inbound': if direction == 'inbound':
# ensure marshmallow.missing is not sent to the client app
for attr in type(self).schema().load_fields.keys(): for attr in type(self).schema().load_fields.keys():
if getattr(self, attr) is missing: if getattr(self, attr) is missing:
setattr(self, attr, None) setattr(self, attr, None)
super().validate(direction) super().validate(direction)
def _validate_signatures(self):
# Always verify the inbound LD signature, for monitoring purposes
actor = verify_ld_signature(self._source_object)
if not self._sender:
return
if self.signable and self._sender not in (self.id, getattr(self, 'actor_id', None)):
# Relayed payload
if not actor:
raise InvalidSignature('no or invalid signature for %s, a relayed payload', self.id)
def to_string(self): def to_string(self):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
return str(self.to_as2()) return str(self.to_as2())
@ -457,6 +468,7 @@ class Link(metaclass=JsonLDAnnotation):
class Hashtag(Link): class Hashtag(Link):
ctx = [{'Hashtag': 'as:Hashtag'}] ctx = [{'Hashtag': 'as:Hashtag'}]
id = fields.Id() # Hubzilla uses id instead of href
class Meta: class Meta:
rdf_type = as2.Hashtag rdf_type = as2.Hashtag
@ -702,7 +714,7 @@ class Note(Object, RawContentMixin):
self._allowed_children += (base.Audio, base.Video) self._allowed_children += (base.Audio, base.Video)
def to_as2(self): def to_as2(self):
#self.sensitive = 'nsfw' in self.tags self.sensitive = 'nsfw' in self.tags
self.url = self.id self.url = self.id
edited = False edited = False
@ -768,7 +780,13 @@ class Note(Object, RawContentMixin):
# Skip when markdown # Skip when markdown
return return
hrefs = [tag.href.lower() for tag in self.tag_objects if isinstance(tag, Hashtag)] hrefs = []
for tag in self.tag_objects:
if isinstance(tag, Hashtag):
if tag.href is not missing:
hrefs.append(tag.href.lower())
elif tag.id is not missing:
hrefs.append(tag.id.lower())
# noinspection PyUnusedLocal # noinspection PyUnusedLocal
def remove_tag_links(attrs, new=False): def remove_tag_links(attrs, new=False):
# Hashtag object hrefs # Hashtag object hrefs
@ -807,6 +825,7 @@ class Note(Object, RawContentMixin):
Populate tags to the object.tag list. Populate tags to the object.tag list.
""" """
try: try:
from federation.utils.django import get_configuration
config = get_configuration() config = get_configuration()
except ImportError: except ImportError:
tags_path = None tags_path = None
@ -1321,6 +1340,7 @@ def element_to_objects(element: Union[Dict, Object], sender: str = "") -> List:
entity = model_to_objects(element) if not isinstance(element, Object) else element entity = model_to_objects(element) if not isinstance(element, Object) else element
if entity and hasattr(entity, 'to_base'): if entity and hasattr(entity, 'to_base'):
entity = entity.to_base() entity = entity.to_base()
entity._sender = sender
if isinstance(entity, ( if isinstance(entity, (
base.Post, base.Comment, base.Profile, base.Share, base.Follow, base.Post, base.Comment, base.Profile, base.Share, base.Follow,
base.Retraction, base.Accept,) base.Retraction, base.Accept,)
@ -1330,13 +1350,11 @@ def element_to_objects(element: Union[Dict, Object], sender: str = "") -> List:
except ValueError as ex: except ValueError as ex:
logger.error("Failed to validate entity %s: %s", entity, ex) logger.error("Failed to validate entity %s: %s", entity, ex)
return [] return []
# Always verify the LD signature, for monitoring purposes except InvalidSignature as exc:
actor = verify_ld_signature(entity._source_object) logger.info('%s, fetching from remote', exc)
if entity.signable and sender not in (entity.id, getattr(entity, 'actor_id', None), ''): entity = retrieve_and_parse_document(entity.id)
# Relayed payload if not entity:
if not actor: return []
logger.warning(f'no or invalid signature for a relayed payload, fetching {entity.id}')
entity = retrieve_and_parse_document(entity.id)
logger.info('Entity type "%s" was handled through the json-ld processor', entity.__class__.__name__) logger.info('Entity type "%s" was handled through the json-ld processor', entity.__class__.__name__)
return [entity] return [entity]
elif entity: elif entity:
@ -1355,7 +1373,7 @@ def model_to_objects(payload):
try: try:
entity = model.schema().load(payload) entity = model.schema().load(payload)
except (KeyError, jsonld.JsonLdError, exceptions.ValidationError) as exc : # Just give up for now. This must be made robust except (KeyError, jsonld.JsonLdError, exceptions.ValidationError) as exc : # Just give up for now. This must be made robust
logger.error(f"Error parsing jsonld payload ({exc})") logger.error("Error parsing jsonld payload (%s)", exc)
return None return None
if isinstance(getattr(entity, 'object_', None), Object): if isinstance(getattr(entity, 'object_', None), Object):

Wyświetl plik

@ -20,6 +20,7 @@ class BaseEntity:
_source_protocol: str = "" _source_protocol: str = ""
# Contains the original object from payload as a string # Contains the original object from payload as a string
_source_object: Union[str, Dict] = None _source_object: Union[str, Dict] = None
_sender: str = ""
_sender_key: str = "" _sender_key: str = ""
# ActivityType # ActivityType
activity: ActivityType = None activity: ActivityType = None
@ -231,8 +232,8 @@ class RawContentMixin(BaseEntity):
@property @property
def rendered_content(self) -> str: def rendered_content(self) -> str:
"""Returns the rendered version of raw_content, or just raw_content.""" """Returns the rendered version of raw_content, or just raw_content."""
from federation.utils.django import get_configuration
try: try:
from federation.utils.django import get_configuration
config = get_configuration() config = get_configuration()
if config["tags_path"]: if config["tags_path"]:
def linkifier(tag: str) -> str: def linkifier(tag: str) -> str:

Wyświetl plik

@ -60,7 +60,7 @@ def rfc7033_webfinger_view(request, *args, **kwargs):
if not resource.startswith("acct:"): if not resource.startswith("acct:"):
return HttpResponseBadRequest("Invalid resource") return HttpResponseBadRequest("Invalid resource")
handle = resource.replace("acct:", "").lower() handle = resource.replace("acct:", "").lower()
logger.debug(f"{handle} requested with {request}") logger.debug("%s requested with %s", handle, request)
profile_func = get_function_from_config("get_profile_function") profile_func = get_function_from_config("get_profile_function")
try: try:

Wyświetl plik

@ -90,6 +90,6 @@ class Protocol:
# Verify the HTTP signature # Verify the HTTP signature
self.sender = verify_request_signature(self.request) self.sender = verify_request_signature(self.request)
except (ValueError, KeyError, InvalidSignature) as exc: except (ValueError, KeyError, InvalidSignature) as exc:
logger.warning(f'HTTP signature verification failed: {exc}') logger.warning('HTTP signature verification failed: %s', exc)
return self.actor, {} return self.actor, {}
return self.sender, self.payload return self.sender, self.payload

Wyświetl plik

@ -79,9 +79,3 @@ def verify_request_signature(request: RequestType, required: bool=True):
raise ValueError("Invalid signature") raise ValueError("Invalid signature")
return signer.id return signer.id

Wyświetl plik

@ -3,14 +3,17 @@ import logging
from typing import Optional, Any from typing import Optional, Any
from federation.protocols.activitypub.signing import get_http_authentication from federation.protocols.activitypub.signing import get_http_authentication
from federation.utils.django import get_federation_user
from federation.utils.network import fetch_document, try_retrieve_webfinger_document from federation.utils.network import fetch_document, try_retrieve_webfinger_document
from federation.utils.text import decode_if_bytes, validate_handle from federation.utils.text import decode_if_bytes, validate_handle
logger = logging.getLogger('federation') logger = logging.getLogger('federation')
federation_user = get_federation_user() try:
if not federation_user: logger.warning("django is required for get requests signing") from federation.utils.django import get_federation_user
federation_user = get_federation_user()
except ImportError:
federation_user = None
logger.warning("django is required for get requests signing")
def get_profile_id_from_webfinger(handle: str) -> Optional[str]: def get_profile_id_from_webfinger(handle: str) -> Optional[str]: