federation/federation/protocols/diaspora/protocol.py

423 wiersze
16 KiB
Python

import json
import logging
import warnings
from base64 import b64decode, urlsafe_b64decode, b64encode, urlsafe_b64encode
from urllib.parse import unquote_plus
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.exceptions import EncryptedMessageError, NoSenderKeyFoundError, SignatureVerificationError
from federation.protocols.base import BaseProtocol
from federation.protocols.diaspora.magic_envelope import MagicEnvelope
logger = logging.getLogger("federation")
PROTOCOL_NAME = "diaspora"
PROTOCOL_NS = "https://joindiaspora.com/protocol"
MAGIC_ENV_TAG = "{http://salmon-protocol.org/ns/magic-env}env"
def identify_payload(payload):
"""Try to identify whether this is a Diaspora payload.
Try first public message. Then private message. The check if this is a legacy payload.
"""
# Private encrypted JSON payload
try:
data = json.loads(payload)
if "encrypted_magic_envelope" in data:
return True
except Exception:
pass
# Public XML payload
try:
xml = etree.fromstring(bytes(payload, encoding="utf-8"))
if xml.tag == MAGIC_ENV_TAG:
return True
except Exception:
pass
# Legacy XML payload
try:
xml = unquote_plus(payload)
return xml.find('xmlns="%s"' % PROTOCOL_NS) > -1
except Exception:
pass
return False
class Protocol(BaseProtocol):
"""Diaspora protocol parts
Mostly taken from Pyaspora (https://github.com/lukeross/pyaspora).
"""
def receive(self, payload, user=None, sender_key_fetcher=None, skip_author_verification=False, *args, **kwargs):
"""Receive a payload.
For testing purposes, `skip_author_verification` can be passed. Authorship will not be verified."""
self.user = user
self.get_contact_key = sender_key_fetcher
# Prepare payload
xml = unquote_plus(payload)
xml = xml.lstrip().encode("utf-8")
logger.debug("diaspora.protocol.receive: xml content: %s", xml)
self.doc = etree.fromstring(xml)
# Check for a legacy header
self.find_header()
# Open payload and get actual message
self.content = self.get_message_content()
# Get sender handle
self.sender_handle = self.get_sender()
# Verify the message is from who it claims to be
if not skip_author_verification:
self.verify_signature()
return self.sender_handle, self.content
def _get_user_key(self, user):
if not hasattr(self.user, "private_key") or not self.user.private_key:
if hasattr(self.user, "key") and self.user.key:
warnings.warn("Using `key` in user object for private key has been deprecated. Please "
"have available `private_key` instead. Usage of `key` will be removed after 0.8.0.",
DeprecationWarning)
return self.user.key
raise EncryptedMessageError("Cannot decrypt private message without user key")
return self.user.private_key
def find_header(self):
self.encrypted = self.legacy = False
self.header = self.doc.find(".//{"+PROTOCOL_NS+"}header")
if self.header != None:
# Legacy public header found
self.legacy = True
return
if self.doc.find(".//{" + PROTOCOL_NS + "}encrypted_header") == None:
# No legacy encrypted header found
return
self.legacy = True
if not self.user:
raise EncryptedMessageError("Cannot decrypt private message without user object")
user_private_key = self._get_user_key(self.user)
self.encrypted = True
self.header = self.parse_header(
self.doc.find(".//{"+PROTOCOL_NS+"}encrypted_header").text,
user_private_key
)
def get_sender(self):
if self.legacy:
return self.get_sender_legacy()
return MagicEnvelope.get_sender(self.doc)
def get_sender_legacy(self):
try:
return self.header.find(".//{"+PROTOCOL_NS+"}author_id").text
except AttributeError:
# Look at the message, try various elements
message = etree.fromstring(self.content)
element = message.find(".//sender_handle")
if element is None:
element = message.find(".//diaspora_handle")
if element is None:
return None
return element.text
def get_message_content(self):
"""
Given the Slap XML, extract out the payload.
"""
body = self.doc.find(
".//{http://salmon-protocol.org/ns/magic-env}data").text
if self.encrypted:
body = self._get_encrypted_body(body)
else:
body = urlsafe_b64decode(body.encode("ascii"))
return body
def _get_encrypted_body(self, body):
"""
Decrypt the body of the payload.
"""
inner_iv = b64decode(self.header.find(".//iv").text.encode("ascii"))
inner_key = b64decode(
self.header.find(".//aes_key").text.encode("ascii"))
decrypter = AES.new(inner_key, AES.MODE_CBC, inner_iv)
body = b64decode(urlsafe_b64decode(body.encode("ascii")))
body = decrypter.decrypt(body)
body = self.pkcs7_unpad(body)
return body
def verify_signature(self):
"""
Verify the signed XML elements to have confidence that the claimed
author did actually generate this message.
"""
sender_key = self.get_contact_key(self.sender_handle)
if not sender_key:
raise NoSenderKeyFoundError("Could not find a sender contact to retrieve key")
body = self.doc.find(
".//{http://salmon-protocol.org/ns/magic-env}data").text
sig = self.doc.find(
".//{http://salmon-protocol.org/ns/magic-env}sig").text
sig_contents = '.'.join([
body,
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(RSA.importKey(sender_key))
if not cipher.verify(sig_hash, urlsafe_b64decode(sig)):
raise SignatureVerificationError("Signature cannot be verified using the given contact key")
def parse_header(self, b64data, key):
"""
Extract the header and decrypt it. This requires the User's private
key and hence the passphrase for the key.
"""
decoded_json = b64decode(b64data.encode("ascii"))
rep = json.loads(decoded_json.decode("ascii"))
outer_key_details = self.decrypt_outer_aes_key_bundle(
rep["aes_key"], key)
header = self.get_decrypted_header(
b64decode(rep["ciphertext"].encode("ascii")),
key=b64decode(outer_key_details["key"].encode("ascii")),
iv=b64decode(outer_key_details["iv"].encode("ascii"))
)
return header
def decrypt_outer_aes_key_bundle(self, data, key):
"""
Decrypt the AES "outer key" credentials using the private key and
passphrase.
"""
if not key:
raise EncryptedMessageError("No key to decrypt with")
cipher = PKCS1_v1_5.new(key)
decoded_json = cipher.decrypt(
b64decode(data.encode("ascii")),
sentinel=None
)
return json.loads(decoded_json.decode("ascii"))
def get_decrypted_header(self, ciphertext, key, iv):
"""
Having extracted the AES "outer key" (envelope) information, actually
decrypt the header.
"""
encrypter = AES.new(key, AES.MODE_CBC, iv)
padded = encrypter.decrypt(ciphertext)
xml = self.pkcs7_unpad(padded)
doc = etree.fromstring(xml)
return doc
def pkcs7_unpad(self, data):
"""
Remove the padding bytes that were added at point of encryption.
"""
if isinstance(data, str):
return data[0:-ord(data[-1])]
else:
return data[0:-data[-1]]
def build_send(self, entity, from_user, to_user=None, *args, **kwargs):
"""Build POST data for sending out to remotes."""
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)