From b143a1765b7ed534eec127618896fb8a54cd517f Mon Sep 17 00:00:00 2001 From: Jason Robinson Date: Mon, 6 Jul 2015 01:08:27 +0300 Subject: [PATCH] Start implementing send parts for diaspora protocol --- federation/controllers.py | 5 + federation/entities/diaspora/generators.py | 54 ++++++ federation/protocols/base.py | 16 +- federation/protocols/diaspora/protocol.py | 204 ++++++++++++++++++++- 4 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 federation/entities/diaspora/generators.py diff --git a/federation/controllers.py b/federation/controllers.py index e66969d..14ace19 100644 --- a/federation/controllers.py +++ b/federation/controllers.py @@ -33,3 +33,8 @@ def handle_receive(payload, user=None, sender_key_fetcher=None): entities = mappers.message_to_objects(message) return sender, found_protocol.PROTOCOL_NAME, entities + + +def handle_send(): + """Send.""" + pass diff --git a/federation/entities/diaspora/generators.py b/federation/entities/diaspora/generators.py new file mode 100644 index 0000000..44e97a9 --- /dev/null +++ b/federation/entities/diaspora/generators.py @@ -0,0 +1,54 @@ +from dateutil.tz import tzlocal, tzutc +from lxml import etree + + +def ensure_timezone(dt, tz=None): + """ + Make sure the datetime
has a timezone set, using timezone if it + doesn't. defaults to the local timezone. + """ + if dt.tzinfo is None: + return dt.replace(tzinfo=tz or tzlocal()) + else: + return dt + + +class EntityConverter(object): + + def __init__(self, entity): + self.entity = entity + self.entity_type = entity.__class__.__name__.lower() + + def struct_to_xml(self, node, struct): + """ + Turn a list of dicts into XML nodes with tag names taken from the dict + keys and element text taken from dict values. This is a list of dicts + so that the XML nodes can be ordered in the XML output. + """ + for obj in struct: + for k, v in obj.items(): + etree.SubElement(node, k).text = v + + def convert_to_xml(self): + if hasattr(self, "%s_to_xml" % self.entity_type): + method_name = "%s_to_xml" % self.entity_type + return method_name() + + def format_dt(cls, dt): + """ + Format a datetime in the way that D* nodes expect. + """ + return ensure_timezone(dt).astimezone(tzutc()).strftime( + '%Y-%m-%d %H:%M:%S %Z' + ) + + def post_to_xml(self): + req = etree.Element("status_message") + self.struct_to_xml(req, [ + {'raw_message': self.entity.raw_content}, + {'guid': self.entity.guid}, + {'diaspora_handle': self.entity.handle}, + {'public': 'true' if self.entity.public else 'false'}, + {'created_at': self.format_dt(self.entity.created_at)} + ]) + return req diff --git a/federation/protocols/base.py b/federation/protocols/base.py index bd533cf..0f48de3 100644 --- a/federation/protocols/base.py +++ b/federation/protocols/base.py @@ -23,12 +23,18 @@ class BaseProtocol(object): logger = logging.getLogger(__name__) - def _build(self, *args, **kwargs): - """Build a payload.""" - raise NotImplementedError("Implement in subclass") + def build_send(self, *args, **kwargs): + """Build a payload for sending. - def send(self, *args, **kwargs): - """Send a payload.""" + Args: + from_user (obj) - The user object who is sending + Must contain attributes `handle` and `private_key` + to_user (obj) - The user object we are sending to + Must contain attribute `key` (public key) + generator (function) - Generator function to generate object for sending + + + """ raise NotImplementedError("Implement in subclass") def receive(self, payload, user=None, sender_key_fetcher=None, *args, **kwargs): diff --git a/federation/protocols/diaspora/protocol.py b/federation/protocols/diaspora/protocol.py index f6782da..c37a0d5 100644 --- a/federation/protocols/diaspora/protocol.py +++ b/federation/protocols/diaspora/protocol.py @@ -1,13 +1,15 @@ -from base64 import b64decode, urlsafe_b64decode, b64encode -from json import loads -from urllib.parse import unquote_plus +from base64 import b64decode, urlsafe_b64decode, b64encode, urlsafe_b64encode +from json import loads, dumps +from urllib.parse import unquote_plus, quote_plus, urlencode from Crypto.Cipher import AES, PKCS1_v1_5 from Crypto.Hash import SHA256 from Crypto.PublicKey import RSA +from Crypto.Random import get_random_bytes from Crypto.Signature import PKCS1_v1_5 as PKCSSign from lxml import etree +from federation.entities.diaspora.generators import EntityConverter from federation.exceptions import EncryptedMessageError, NoHeaderInMessageError, NoSenderKeyFoundError from federation.protocols.base import BaseProtocol @@ -152,3 +154,199 @@ class Protocol(BaseProtocol): return data[0:-ord(data[-1])] else: return data[0:-data[-1]] + + def build_send(self, from_user, to_user, entity, *args, **kwargs): + """Build POST data for sending out to remotes.""" + converter = EntityConverter(entity) + xml = converter.convert_to_xml() + self.init_message(xml, from_user.handle, from_user.private_key) + xml = quote_plus( + self.create_salmon_envelope(to_user.key)) + data = urlencode({ + 'xml': xml + }) + return data + + def init_message(self, message, author_username, private_key): + """ + Build a Diaspora message and prepare to send the payload , + authored by Contact . The receipient is specified later, so + that the same message can be sent to several people without needing to + keep re-encrypting the inner. + """ + + # We need an AES key for the envelope + self.inner_iv = get_random_bytes(AES.block_size) + self.inner_key = get_random_bytes(32) + self.inner_encrypter = AES.new( + self.inner_key, AES.MODE_CBC, self.inner_iv) + + # ...and one for the payload message + self.outer_iv = get_random_bytes(AES.block_size) + self.outer_key = get_random_bytes(32) + self.outer_encrypter = AES.new( + self.outer_key, AES.MODE_CBC, self.outer_iv) + self.message = message + self.author_username = author_username + self.private_key = private_key + + def xml_to_string(self, doc, xml_declaration=False): + """ + Utility function to turn an XML document to a string. This is + abstracted out so that pretty-printing can be turned on and off in one + place. + """ + return etree.tostring( + doc, + xml_declaration=xml_declaration, + pretty_print=True, + encoding="UTF-8" + ) + + def create_decrypted_header(self): + """ + Build the XML document for the header. The header contains the key + used to encrypt the message body. + """ + decrypted_header = etree.Element('decrypted_header') + etree.SubElement(decrypted_header, "iv").text = \ + b64encode(self.inner_iv) + etree.SubElement(decrypted_header, "aes_key").text = \ + b64encode(self.inner_key) + etree.SubElement(decrypted_header, "author_id").text = \ + self.author_username + return self.xml_to_string(decrypted_header) + + def create_public_header(self): + decrypted_header = etree.Element('header') + etree.SubElement(decrypted_header, "author_id").text = \ + self.author_username + return decrypted_header + + def create_ciphertext(self): + """ + Encrypt the header. + """ + to_encrypt = self.pkcs7_pad( + self.create_decrypted_header(), + AES.block_size + ) + out = self.outer_encrypter.encrypt(to_encrypt) + return out + + def create_outer_aes_key_bundle(self): + """ + Record the information on the key used to encrypt the header. + """ + d = dumps({ + "iv": b64encode(self.outer_iv).decode("ascii"), + "key": b64encode(self.outer_key).decode("ascii") + }) + return d + + def create_encrypted_outer_aes_key_bundle(self, recipient_rsa): + """ + The Outer AES Key Bundle is encrypted with the receipient's public + key, so only the receipient can decrypt the header. + """ + cipher = PKCS1_v1_5.new(recipient_rsa) + return cipher.encrypt( + self.create_outer_aes_key_bundle().encode("utf-8")) + + def create_encrypted_header_json_object(self, public_key): + """ + The actual header and the encrypted outer (header) key are put into a + document together. + """ + aes_key = b64encode(self.create_encrypted_outer_aes_key_bundle( + public_key)).decode("ascii") + ciphertext = b64encode(self.create_ciphertext()).decode("ascii") + + d = dumps({ + "aes_key": aes_key, + "ciphertext": ciphertext + }) + return d + + def create_encrypted_header(self, public_key): + """ + The "encrypted header JSON object" is dropped into some XML. I am not + sure what this is for, but is required to interact. + """ + doc = etree.Element("encrypted_header") + doc.text = b64encode(self.create_encrypted_header_json_object( + public_key).encode("ascii")) + return doc + + def create_payload(self): + """ + Wrap the actual payload message in the standard XML wrapping. + """ + doc = etree.Element("XML") + inner = etree.SubElement(doc, "post") + if isinstance(self.message, str): + inner.text = self.message + else: + inner.append(self.message) + return self.xml_to_string(doc) + + def create_encrypted_payload(self): + """ + Encrypt the payload XML with the inner (body) key. + """ + to_encrypt = self.pkcs7_pad(self.create_payload(), AES.block_size) + return self.inner_encrypter.encrypt(to_encrypt) + + def create_salmon_envelope(self, recipient_public_key): + """ + Build the whole message, pulling together the encrypted payload and the + encrypted header. Selected elements are signed by the author so that + tampering can be detected. + """ + nsmap = { + None: PROTOCOL_NS, + 'me': 'http://salmon-protocol.org/ns/magic-env' + } + doc = etree.Element("{%s}diaspora" % nsmap[None], nsmap=nsmap) + if recipient_public_key: + doc.append(self.create_encrypted_header(recipient_public_key)) + else: + doc.append(self.create_public_header()) + env = etree.SubElement(doc, "{%s}env" % nsmap["me"]) + etree.SubElement(env, "{%s}encoding" % nsmap["me"]).text = 'base64url' + etree.SubElement(env, "{%s}alg" % nsmap["me"]).text = 'RSA-SHA256' + if recipient_public_key: + payload = urlsafe_b64encode(b64encode( + self.create_encrypted_payload())).decode("ascii") + else: + payload = urlsafe_b64encode(self.create_payload()).decode("ascii") + # Split every 60 chars + payload = '\n'.join([payload[start:start+60] + for start in range(0, len(payload), 60)]) + payload = payload + "\n" + etree.SubElement(env, "{%s}data" % nsmap["me"], + {"type": "application/xml"}).text = payload + sig_contents = payload + "." + \ + b64encode(b"application/xml").decode("ascii") + "." + \ + b64encode(b"base64url").decode("ascii") + "." + \ + b64encode(b"RSA-SHA256").decode("ascii") + sig_hash = SHA256.new(sig_contents.encode("ascii")) + cipher = PKCSSign.new(self.private_key) + sig = urlsafe_b64encode(cipher.sign(sig_hash)) + etree.SubElement(env, "{%s}sig" % nsmap["me"]).text = sig + return self.xml_to_string(doc) + + def pkcs7_pad(self, inp, block_size): + """ + Using the PKCS#7 padding scheme, pad to be a multiple of + bytes. Ruby's AES encryption pads with this scheme, but + pycrypto doesn't support it. + """ + val = block_size - len(inp) % block_size + if val == 0: + return inp + (self.array_to_bytes([block_size]) * block_size) + else: + return inp + (self.array_to_bytes([val]) * val) + + def array_to_bytes(self, vals): + return bytes(vals)