From 268afb950b8274f1ddde390279de2a780b7fb721 Mon Sep 17 00:00:00 2001 From: Jason Robinson Date: Sat, 29 Apr 2017 22:07:28 +0300 Subject: [PATCH] Add relayable signature verification for DiasporaComment --- federation/entities/base.py | 9 +++++ federation/entities/diaspora/entities.py | 9 +++++ federation/entities/diaspora/mappers.py | 26 +++++++++---- federation/protocols/diaspora/signatures.py | 21 ++++++++++- .../tests/entities/diaspora/test_mappers.py | 4 +- .../protocols/diaspora/test_signatures.py | 37 +++++++++++++++++++ 6 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 federation/tests/protocols/diaspora/test_signatures.py diff --git a/federation/entities/base.py b/federation/entities/base.py index c91a871..198b9b7 100644 --- a/federation/entities/base.py +++ b/federation/entities/base.py @@ -15,6 +15,7 @@ class BaseEntity(object): _children = [] _allowed_children = () _source_protocol = "" + _source_object = None def __init__(self, *args, **kwargs): self._required = [] @@ -33,6 +34,7 @@ class BaseEntity(object): 2) Make sure all attrs in required have a non-empty value 3) Loop through attributes and call their `validate_` methods, if any. 4) Validate allowed children + 5) Validate signatures """ attributes = [] validates = [] @@ -48,6 +50,7 @@ class BaseEntity(object): self._validate_required(attributes) self._validate_attributes(validates) self._validate_children() + self._validate_signatures() def _validate_required(self, attributes): """Ensure required attributes are present.""" @@ -82,6 +85,11 @@ class BaseEntity(object): ) ) + def _validate_signatures(self): + """Override in subclasses where necessary""" + pass + + class GUIDMixin(BaseEntity): guid = "" @@ -164,6 +172,7 @@ class SignedMixin(BaseEntity): This will be needed to for example when relaying content. """ + _sender_key = "" signature = "" def __init__(self, *args, **kwargs): diff --git a/federation/entities/diaspora/entities.py b/federation/entities/diaspora/entities.py index c0c28eb..0ec63cc 100644 --- a/federation/entities/diaspora/entities.py +++ b/federation/entities/diaspora/entities.py @@ -3,6 +3,8 @@ from lxml import etree from federation.entities.base import Comment, Post, Reaction, Relationship, Profile, Retraction from federation.entities.diaspora.utils import format_dt, struct_to_xml, get_base_attributes +from federation.exceptions import SignatureVerificationError +from federation.protocols.diaspora.signatures import verify_relayable_signature from federation.utils.diaspora import retrieve_and_parse_profile @@ -39,6 +41,13 @@ class DiasporaComment(DiasporaEntityMixin, Comment): ]) return element + def _validate_signatures(self): + super()._validate_signatures() + if not self._sender_key: + raise SignatureVerificationError("Cannot verify entity signature - no sender key available") + if not verify_relayable_signature(self._sender_key, self._source_object, self.signature): + raise SignatureVerificationError("Signature verification failed.") + class DiasporaPost(DiasporaEntityMixin, Post): """Diaspora post, ie status message.""" diff --git a/federation/entities/diaspora/mappers.py b/federation/entities/diaspora/mappers.py index 88f021d..652f50e 100644 --- a/federation/entities/diaspora/mappers.py +++ b/federation/entities/diaspora/mappers.py @@ -4,10 +4,10 @@ from datetime import datetime from lxml import etree -from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile, Retraction +from federation.entities.base import Image, Relationship, Post, Reaction, Comment, Profile, Retraction, SignedMixin from federation.entities.diaspora.entities import ( DiasporaPost, DiasporaComment, DiasporaLike, DiasporaRequest, DiasporaProfile, DiasporaRetraction) - +from federation.utils.diaspora import retrieve_and_parse_profile logger = logging.getLogger("federation") @@ -43,7 +43,7 @@ def xml_children_as_dict(node): return dict((e.tag, e.text) for e in node) -def element_to_objects(tree): +def element_to_objects(tree, sender_key_fetcher=None): """Transform an Element tree to a list of entities recursively. Possible child entities are added to each entity `_children` list. @@ -62,6 +62,18 @@ def element_to_objects(tree): if hasattr(cls, "fill_extra_attributes"): transformed = cls.fill_extra_attributes(transformed) entity = cls(**transformed) + # Add protocol name + entity._source_protocol = "diaspora" + # Save element object to entity for possible later use + entity._source_object = element + # If signable, fetch sender key + if issubclass(cls, SignedMixin): + if sender_key_fetcher: + entity._sender_key = sender_key_fetcher(entity.handle) + else: + profile = retrieve_and_parse_profile(entity.handle) + if profile: + entity._sender_key = profile.public_key try: entity.validate() except ValueError as ex: @@ -72,8 +84,6 @@ def element_to_objects(tree): continue # Do child elements entity._children = element_to_objects(element) - # Add protocol name - entity._source_protocol = "diaspora" # Add to entities list entities.append(entity) if cls == DiasporaRequest: @@ -84,18 +94,20 @@ def element_to_objects(tree): return entities -def message_to_objects(message): +def message_to_objects(message, sender_key_fetcher=None): """Takes in a message extracted by a protocol and maps it to entities. :param message: XML payload :type message: str + :param sender_key_fetcher: Function to fetch sender public key. If not given, key will always be fetched + over network :returns: list of entities """ doc = etree.fromstring(message) if doc[0].tag == "post": # Skip the top element if it exists doc = doc[0] - entities = element_to_objects(doc) + entities = element_to_objects(doc, sender_key_fetcher) return entities diff --git a/federation/protocols/diaspora/signatures.py b/federation/protocols/diaspora/signatures.py index 24ba199..7112003 100644 --- a/federation/protocols/diaspora/signatures.py +++ b/federation/protocols/diaspora/signatures.py @@ -16,10 +16,29 @@ Example Comment payload TODO: -* Add method to verify author signature * Add method to create author signature https://diaspora.github.io/diaspora_federation/federation/relayable.html parent_author_signature part can be skipped as per discussion with Diaspora protcol team. """ +from base64 import urlsafe_b64decode + +from Crypto.Hash import SHA256 +from Crypto.PublicKey import RSA +from Crypto.Signature import PKCS1_v1_5 + + +def verify_relayable_signature(public_key, doc, signature): + """ + Verify the signed XML elements to have confidence that the claimed + author did actually generate this message. + """ + props = [] + for child in doc: + if child.tag not in ["author_signature", "parent_author_signature"]: + props.append(child.text) + content = ";".join(props) + sig_hash = SHA256.new(content.encode("ascii")) + cipher = PKCS1_v1_5.new(RSA.importKey(public_key)) + return cipher.verify(sig_hash, urlsafe_b64decode(signature)) diff --git a/federation/tests/entities/diaspora/test_mappers.py b/federation/tests/entities/diaspora/test_mappers.py index e2286db..b57f305 100644 --- a/federation/tests/entities/diaspora/test_mappers.py +++ b/federation/tests/entities/diaspora/test_mappers.py @@ -73,7 +73,8 @@ class TestDiasporaEntityMappersReceive(object): assert photo.public == False assert photo.created_at == datetime(2011, 7, 20, 1, 36, 7) - def test_message_to_objects_comment(self): + @patch("federation.entities.diaspora.mappers.DiasporaComment._validate_signatures") + def test_message_to_objects_comment(self, mock_validate): entities = message_to_objects(DIASPORA_POST_COMMENT) assert len(entities) == 1 comment = entities[0] @@ -85,6 +86,7 @@ class TestDiasporaEntityMappersReceive(object): assert comment.participation == "comment" assert comment.raw_content == "((text))" assert comment.signature == "((signature))" + mock_validate.assert_called_once_with() def test_message_to_objects_like(self): entities = message_to_objects(DIASPORA_POST_LIKE) diff --git a/federation/tests/protocols/diaspora/test_signatures.py b/federation/tests/protocols/diaspora/test_signatures.py new file mode 100644 index 0000000..960ecaa --- /dev/null +++ b/federation/tests/protocols/diaspora/test_signatures.py @@ -0,0 +1,37 @@ +from lxml import etree + +from federation.protocols.diaspora.signatures import verify_relayable_signature + +xml = "0dd40d800db1013514416c626dd5570369ab2b83-aa69-4456-ad0a-dd669" \ + "7f54714Woop Woopjaywink@iliketoast.net" \ + "A/vVRxM3V1ceEH1JrnPOaIZGM3gMjw/fnT9TgUh3poI4q9eH95AIoig+3eTA8XFuGvuo0tivxci4e0NJ1VLVkl/aqp8" \ + "rvBNrRI1RQkn2WVF6zk15Gq6KSia/wyzyiJHGxNGM8oFY4qPfNp6K+8ydUti22J11tVBEvQn+7FPAoloF2Xz1waK48ZZCFs8Rxzj+4jlz1Pmu" \ + "XCnTj7v7GYS1Rb6sdFz4nBSuVk5X8tGOSXIRYxPgmtsDRMRrvDeEK+v3OY6VnT8dLTckS0qCwTRUULub1CGwkz/2mReZk/M1W4EbUnugF5pts" \ + "lmFqYDYJZM8PA/g89EKVpkx2gaFbsC4KXocWnxHNiue18rrFQ5hMnDuDRiRybLnQkxXbE/HDuLdnognt2S5wRshPoZmhe95v3qq/5nH/GX1D7" \ + "VmxEEIG9fX+XX+Vh9kzO9bLbwoJZwm50zXxCvrLlye/2JU5Vd2Hbm4aMuAyRAZiLS/EQcBlsts4DaFu4txe60HbXSh6nqNofGkusuzZnCd0VO" \ + "bOpXizrI8xNQzZpjJEB5QqE2gbCC2YZNdOS0eBGXw42dAXa/QV3jZXGES7DdQlqPqqT3YjcMFLiRrWQR8cl4hJIBRpV5piGyLmMMKYrWu7hQS" \ + "rdRAEL3K6mNZZU6/yoG879LjtQbVwaFGPeT29B4zBE97FIo=" \ + "" + +signature = "A/vVRxM3V1ceEH1JrnPOaIZGM3gMjw/fnT9TgUh3poI4q9eH95AIoig+3eTA8XFuGvuo0tivxci4e0NJ1VLVkl/aqp8rvBNrRI1RQk" \ + "n2WVF6zk15Gq6KSia/wyzyiJHGxNGM8oFY4qPfNp6K+8ydUti22J11tVBEvQn+7FPAoloF2Xz1waK48ZZCFs8Rxzj+4jlz1PmuXCnT" \ + "j7v7GYS1Rb6sdFz4nBSuVk5X8tGOSXIRYxPgmtsDRMRrvDeEK+v3OY6VnT8dLTckS0qCwTRUULub1CGwkz/2mReZk/M1W4EbUnugF5" \ + "ptslmFqYDYJZM8PA/g89EKVpkx2gaFbsC4KXocWnxHNiue18rrFQ5hMnDuDRiRybLnQkxXbE/HDuLdnognt2S5wRshPoZmhe95v3qq" \ + "/5nH/GX1D7VmxEEIG9fX+XX+Vh9kzO9bLbwoJZwm50zXxCvrLlye/2JU5Vd2Hbm4aMuAyRAZiLS/EQcBlsts4DaFu4txe60HbXSh6n" \ + "qNofGkusuzZnCd0VObOpXizrI8xNQzZpjJEB5QqE2gbCC2YZNdOS0eBGXw42dAXa/QV3jZXGES7DdQlqPqqT3YjcMFLiRrWQR8cl4h" \ + "JIBRpV5piGyLmMMKYrWu7hQSrdRAEL3K6mNZZU6/yoG879LjtQbVwaFGPeT29B4zBE97FIo=" + +pubkey = "-----BEGIN PUBLIC KEY-----\nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuCfU1G5X+3O6vPdSz6QY\nSFbgdbv3KPv" \ + "xHi8tRmlyOLdLt5i1eqsy2WCW1iYNijiCL7OfbrvymBQxe3GA9S64\nVuavwzQ8nO7nzpNMqxY5tBXsBM1lECCHDOvm5dzINXWT9Sg7P1" \ + "8iIxE/2wQEgMUL\nAeVbJtAriXM4zydL7c91agFMJu1aHp0lxzoH8I13xzUetGMutR1tbcfWvoQvPAoU\n89uAz5j/DFMhWrkVEKGeWt1" \ + "YtHMmJqpYqR6961GDlwRuUsOBsLgLLVohzlBsTBSn\n3580o2E6G3DEaX0Az9WB9ylhNeV/L/PP3c5htpEyoPZSy1pgtut6TRYQwC8wns" \ + "qO\nbVIbFBkrKoaRDyVCnpMuKdDNLZqOOfhzas+SWRAby6D8VsXpPi/DpeS9XkX0o/uH\nJ9N49GuYMSUGC8gKtaddD13pUqS/9rpSvLD" \ + "rrDQe5Lhuyusgd28wgEAPCTmM3pEt\nQnlxEeEmFMIn3OBLbEDw5TFE7iED0z7a4dAkqqz8KCGEt12e1Kz7ujuOVMxJxzk6\nNtwt40Sq" \ + "EOPcdsGHAA+hqzJnXUihXfmtmFkropaCxM2f+Ha0bOQdDDui5crcV3sX\njShmcqN6YqFzmoPK0XM9P1qC+lfL2Mz6bHC5p9M8/FtcM46" \ + "hCj1TF/tl8zaZxtHP\nOrMuFJy4j4yAsyVy3ddO69ECAwEAAQ==\n-----END PUBLIC KEY-----\n" + + +def test_verify_relayable_signature(): + doc = etree.XML(xml) + root = doc.find(".//comment") + assert verify_relayable_signature(pubkey, root, signature)