Send outbound Diaspora payloads in new format

Remove possibility to generate legacy MagicEnvelope payloads.

Refs: #82
merge-requests/130/head
Jason Robinson 2018-02-04 00:19:53 +02:00
rodzic f6091d270a
commit c6bbd3ac4b
3 zmienionych plików z 38 dodań i 214 usunięć

Wyświetl plik

@ -1,5 +1,11 @@
# Changelog
## [unreleased]
### Changed
* Send outbound Diaspora payloads in new format. Remove possibility to generate legacy MagicEnvelope payloads. ([related issue](https://github.com/jaywink/federation/issues/82))
## [0.15.0] - 2018-02-12
### Added

Wyświetl plik

@ -1,12 +1,9 @@
import json
import logging
from base64 import b64decode, urlsafe_b64decode, b64encode, urlsafe_b64encode
from base64 import b64decode, urlsafe_b64decode
from urllib.parse import unquote_plus
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.Hash import SHA256
from Crypto.Random import get_random_bytes
from Crypto.Signature import PKCS1_v1_5 as PKCSSign
from lxml import etree
from federation.exceptions import EncryptedMessageError, NoSenderKeyFoundError
@ -234,200 +231,11 @@ class Protocol(BaseProtocol):
def build_send(self, entity, from_user, to_user=None, *args, **kwargs):
"""Build POST data for sending out to remotes."""
if entity.outbound_doc:
if entity.outbound_doc is not None:
# Use pregenerated outbound document
xml = entity.outbound_doc
else:
xml = entity.to_xml()
self.init_message(xml, from_user.handle, from_user.private_key)
xml = self.create_salmon_envelope(to_user)
return {'xml': xml}
def init_message(self, message, author_username, private_key):
"""
Build a Diaspora message and prepare to send the payload <message>,
authored by Contact <author>. 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 = json.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 = json.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):
"""
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.
Note, this corresponds to the old Diaspora protocol which will slowly be replaced by the
new version. See PR https://github.com/diaspora/diaspora_federation/issues/30
Args:
recipient - Recipient object which must have public key as `key` (private messages only)
Returns:
XML document as string
"""
nsmap = {
None: PROTOCOL_NS,
'me': 'http://salmon-protocol.org/ns/magic-env'
}
doc = etree.Element("{%s}diaspora" % nsmap[None], nsmap=nsmap)
if recipient:
doc.append(self.create_encrypted_header(recipient.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:
payload = urlsafe_b64encode(b64encode(
self.create_encrypted_payload())).decode("ascii")
else:
payload = urlsafe_b64encode(self.create_payload()).decode("ascii")
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 <inp> to be a multiple of
<block_size> 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)
me = MagicEnvelope(etree.tostring(xml), private_key=from_user.private_key, author_handle=from_user.handle)
# TODO wrap if doing encrypted delivery
return me.render()

Wyświetl plik

@ -1,3 +1,4 @@
import datetime
from base64 import urlsafe_b64decode
from unittest.mock import Mock, patch
from xml.etree.ElementTree import ElementTree
@ -5,9 +6,12 @@ from xml.etree.ElementTree import ElementTree
from lxml import etree
import pytest
from federation.entities.base import Post
from federation.entities.diaspora.entities import DiasporaPost
from federation.entities.diaspora.mappers import get_outbound_entity
from federation.exceptions import EncryptedMessageError, NoSenderKeyFoundError, SignatureVerificationError
from federation.protocols.diaspora.protocol import Protocol, identify_payload
from federation.tests.fixtures.keys import PUBKEY
from federation.tests.fixtures.keys import PUBKEY, get_dummy_private_key
from federation.tests.fixtures.payloads import (
ENCRYPTED_LEGACY_DIASPORA_PAYLOAD, UNENCRYPTED_LEGACY_DIASPORA_PAYLOAD, DIASPORA_PUBLIC_PAYLOAD,
DIASPORA_ENCRYPTED_PAYLOAD,
@ -171,26 +175,32 @@ class TestDiasporaProtocol(DiasporaTestBase):
protocol.content = "<content><handle>bob@example.com</handle></content>"
assert protocol.get_sender_legacy() is None
@patch.object(Protocol, "init_message")
@patch.object(Protocol, "create_salmon_envelope")
def test_build_send(self, mock_create_salmon, mock_init_message):
mock_create_salmon.return_value = "xmldata"
protocol = self.init_protocol()
mock_entity_xml = Mock()
entity = Mock(to_xml=Mock(return_value=mock_entity_xml), outbound_doc=None)
from_user = Mock(handle="foobar", private_key="barfoo")
data = protocol.build_send(entity, from_user)
mock_init_message.assert_called_once_with(mock_entity_xml, from_user.handle, from_user.private_key)
assert data == {"xml": "xmldata"}
@patch("federation.protocols.diaspora.protocol.MagicEnvelope")
def test_build_send_does_right_calls(self, mock_me):
mock_render = Mock(return_value="rendered")
mock_me_instance = Mock(render=mock_render)
mock_me.return_value = mock_me_instance
protocol = Protocol()
entity = DiasporaPost()
private_key = get_dummy_private_key()
outbound_entity = get_outbound_entity(entity, private_key)
data = protocol.build_send(outbound_entity, from_user=Mock(
private_key=private_key, handle="johnny@localhost",
))
mock_me.assert_called_once_with(
etree.tostring(entity.to_xml()), private_key=private_key, author_handle="johnny@localhost",
)
mock_render.assert_called_once_with()
assert data == "rendered"
@patch.object(Protocol, "init_message")
@patch.object(Protocol, "create_salmon_envelope")
def test_build_send_uses_outbound_doc(self, mock_create_salmon, mock_init_message):
@patch("federation.protocols.diaspora.protocol.MagicEnvelope")
def test_build_send_uses_outbound_doc(self, mock_me):
protocol = self.init_protocol()
entity = Mock(to_xml=Mock(return_value=Mock()), outbound_doc="outbound_doc")
outbound_doc = etree.fromstring("<xml>foo</xml>")
entity = Mock(outbound_doc=outbound_doc)
from_user = Mock(handle="foobar", private_key="barfoo")
protocol.build_send(entity, from_user)
mock_init_message.assert_called_once_with("outbound_doc", from_user.handle, from_user.private_key)
mock_me.assert_called_once_with(b"<xml>foo</xml>", private_key=from_user.private_key, author_handle="foobar")
@patch("federation.protocols.diaspora.protocol.EncryptedPayload.decrypt")
def test_get_json_payload_magic_envelope(self, mock_decrypt):