Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Add encrypted http pusher (MSC3013) #11512

Open
wants to merge 9 commits into
base: develop
Choose a base branch
from
1 change: 1 addition & 0 deletions changelog.d/11512.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add support for MSC3013 Encrypted Push. Contributed by Famedly GmbH / Sorunome.
130 changes: 108 additions & 22 deletions synapse/push/httppusher.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Copyright 2015, 2016 OpenMarket Ltd
# Copyright 2017 New Vector Ltd
# Copyright 2021 Sorunome
# Copyright 2021 Famedly GmbH
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -12,10 +14,17 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import hashlib
import hmac
import json
import logging
import urllib.parse
from typing import TYPE_CHECKING, Any, Dict, Iterable, Optional, Union

import unpaddedbase64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from donna25519 import PrivateKey, PublicKey # type: ignore
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
from prometheus_client import Counter

from twisted.internet.error import AlreadyCalled, AlreadyCancelled
Expand Down Expand Up @@ -106,9 +115,84 @@ def __init__(self, hs: "HomeServer", pusher_config: PusherConfig):

self.url = url
self.http_client = hs.get_proxied_blacklisted_http_client()
self.data_minus_url = {}
self.data_minus_url.update(self.data)
del self.data_minus_url["url"]
self.sanitized_data = {}
self.sanitized_data.update(self.data)
del self.sanitized_data["url"]

if "algorithm" not in self.data:
self.algorithm = "com.famedly.plain"
elif self.data["algorithm"] not in (
"com.famedly.plain",
"com.famedly.curve25519-aes-sha2",
):
raise PusherConfigException(
"'algorithm' must be one of 'com.famedly.plain' or 'com.famedly.curve25519-aes-sha2'"
)
else:
self.algorithm = self.data["algorithm"]

if self.algorithm == "com.famedly.curve25519-aes-sha2":
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
base64_public_key = self.data["public_key"]
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
if not isinstance(base64_public_key, str):
raise PusherConfigException("'public_key' must be a string")
try:
self.public_key = PublicKey(
unpaddedbase64.decode_base64(base64_public_key)
)
except Exception as e:
logger.warning(
"Failed to unpack public key: %s: %s", type(e).__name__, e
)
raise PusherConfigException(
"'public_key' must be a valid base64-encoded curve25519 public key"
)
del self.sanitized_data["public_key"]

def _process_notification_dict(self, payload: Dict[str, Any]) -> Dict[str, Any]:
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
"""Called to process a payload according to the algorithm the pusher is
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
configured with. Namely, if the algorithm is `com.famedly.curve25519-aes-sha2`
we will encrypt the payload.

Args:
payload: The payload that should be processed
"""
if self.algorithm == "com.famedly.curve25519-aes-sha2":
# we have an encrypted pusher, encrypt the payload
cleartext_notif = payload["notification"]
devices = cleartext_notif["devices"]
del cleartext_notif["devices"]
Comment on lines +183 to +184
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could use

Suggested change
devices = cleartext_notif["devices"]
del cleartext_notif["devices"]
devices = cleartext_notif.pop("devices")

As in https://docs.python.org/3/library/stdtypes.html?highlight=dict%20pop#dict.pop

cleartext = json.dumps(cleartext_notif)
Sorunome marked this conversation as resolved.
Show resolved Hide resolved

# create an ephemeral curve25519 keypair
private_key = PrivateKey()
# do ECDH
secret_key = private_key.do_exchange(self.public_key)
# expand with HKDF
zerosalt = bytes([0] * 32)
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
prk = hmac.new(zerosalt, secret_key, hashlib.sha256).digest()
aes_key = hmac.new(prk, bytes([1]), hashlib.sha256).digest()
mac_key = hmac.new(prk, aes_key + bytes([2]), hashlib.sha256).digest()
aes_iv = hmac.new(prk, mac_key + bytes([3]), hashlib.sha256).digest()[0:16]
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
# create the ciphertext with AES-CBC-256
ciphertext = AES.new(aes_key, AES.MODE_CBC, aes_iv).encrypt(
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
pad(cleartext.encode("utf-8"), AES.block_size)
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
)
# create the mac
mac = hmac.new(mac_key, ciphertext, hashlib.sha256).digest()[0:8]
reivilibre marked this conversation as resolved.
Show resolved Hide resolved

return {
"notification": {
"ephemeral": unpaddedbase64.encode_base64(
private_key.get_public().public
),
"ciphertext": unpaddedbase64.encode_base64(ciphertext),
"mac": unpaddedbase64.encode_base64(mac),
"devices": devices,
}
}

# else fall back to just plaintext
return payload

def on_started(self, should_check_for_notifs: bool) -> None:
"""Called when this pusher has been started.
Expand Down Expand Up @@ -333,12 +417,12 @@ async def _build_notification_dict(
"app_id": self.app_id,
"pushkey": self.pushkey,
"pushkey_ts": int(self.pushkey_ts / 1000),
"data": self.data_minus_url,
"data": self.sanitized_data,
}
],
}
}
return d
return self._process_notification_dict(d)

ctx = await push_tools.get_context_for_event(self.storage, event, self.user_id)

Expand All @@ -359,7 +443,7 @@ async def _build_notification_dict(
"app_id": self.app_id,
"pushkey": self.pushkey,
"pushkey_ts": int(self.pushkey_ts / 1000),
"data": self.data_minus_url,
"data": self.sanitized_data,
"tweaks": tweaks,
}
],
Expand All @@ -378,7 +462,7 @@ async def _build_notification_dict(
if "name" in ctx and len(ctx["name"]) > 0:
d["notification"]["room_name"] = ctx["name"]

return d
return self._process_notification_dict(d)

async def dispatch_push(
self, event: EventBase, tweaks: Dict[str, bool], badge: int
Expand Down Expand Up @@ -410,22 +494,24 @@ async def _send_badge(self, badge: int) -> None:
badge: number of unread messages
"""
logger.debug("Sending updated badge count %d to %s", badge, self.name)
d = {
"notification": {
"id": "",
"type": None,
"sender": "",
"counts": {"unread": badge},
"devices": [
{
"app_id": self.app_id,
"pushkey": self.pushkey,
"pushkey_ts": int(self.pushkey_ts / 1000),
"data": self.data_minus_url,
}
],
d = self._process_notification_dict(
{
"notification": {
"id": "",
"type": None,
"sender": "",
"counts": {"unread": badge},
"devices": [
{
"app_id": self.app_id,
"pushkey": self.pushkey,
"pushkey_ts": int(self.pushkey_ts / 1000),
"data": self.sanitized_data,
}
],
}
}
}
)
try:
await self.http_client.post_json_get_json(self.url, d)
http_badges_processed_counter.inc()
Expand Down
3 changes: 3 additions & 0 deletions synapse/python_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@
# with the latest security patches.
"cryptography>=3.4.7",
"ijson>=3.1",
# Used for encrypted push
"pycryptodome>=3",
Sorunome marked this conversation as resolved.
Show resolved Hide resolved
"donna25519>=0.1.1",
]

CONDITIONAL_REQUIREMENTS = {
Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/client/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ def on_GET(self, request: Request) -> Tuple[int, JsonDict]:
"org.matrix.msc3026.busy_presence": self.config.experimental.msc3026_enabled,
# Supports receiving hidden read receipts as per MSC2285
"org.matrix.msc2285": self.config.experimental.msc2285_enabled,
# Adds support for encrypted push
"com.famedly.msc3013": True,
},
},
)
Expand Down
107 changes: 107 additions & 0 deletions tests/push/test_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import hashlib
import hmac
import json
from unittest.mock import Mock

import unpaddedbase64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from donna25519 import PrivateKey, PublicKey # type: ignore

from twisted.internet.defer import Deferred

import synapse.rest.admin
Expand Down Expand Up @@ -198,6 +206,105 @@ def test_sends_http(self):
self.assertEqual(len(pushers), 1)
self.assertTrue(pushers[0].last_stream_ordering > last_stream_ordering)

def test_sends_encrypted_push(self):
"""
The HTTP pusher will send an encrypted push message if the pusher
has been configured with a public key and the corresponding algorithm
"""
private_key = "ocE2RWd/yExYEk0JCAx3100//WQkmM3syidCVFsndS0="
public_key = "odb+sBwaK0bZtaAqzcuFR3UVg5Wa1cW7ZMwJY1SnDng"

# Register the user who gets notified
user_id = self.register_user("user", "pass")
access_token = self.login("user", "pass")

# Register the user who sends the message
other_user_id = self.register_user("otheruser", "pass")
other_access_token = self.login("otheruser", "pass")

# Register the pusher
user_tuple = self.get_success(
self.hs.get_datastore().get_user_by_access_token(access_token)
)
token_id = user_tuple.token_id

self.get_success(
self.hs.get_pusherpool().add_pusher(
user_id=user_id,
access_token=token_id,
kind="http",
app_id="m.http",
app_display_name="HTTP Push Notifications",
device_display_name="pushy push",
pushkey="[email protected]",
lang=None,
data={
"url": "http://example.com/_matrix/push/v1/notify",
"algorithm": "com.famedly.curve25519-aes-sha2",
"public_key": public_key,
},
)
)

# Create a room
room = self.helper.create_room_as(user_id, tok=access_token)

# The other user joins
self.helper.join(room=room, user=other_user_id, tok=other_access_token)

# The other user sends some messages
self.helper.send(room, body="Foxes are cute!", tok=other_access_token)

# Advance time a bit, so the pusher will register something has happened
self.pump()

# Make the push succeed
self.push_attempts[0][0].callback({})
self.pump()

# One push was attempted to be sent -- it'll be the first message
self.assertEqual(len(self.push_attempts), 1)
self.assertEqual(
self.push_attempts[0][1], "http://example.com/_matrix/push/v1/notify"
)
self.assertEqual(
self.push_attempts[0][2]["notification"]["devices"][0]["data"]["algorithm"],
"com.famedly.curve25519-aes-sha2",
)
ephemeral = unpaddedbase64.decode_base64(
self.push_attempts[0][2]["notification"]["ephemeral"]
)
mac = unpaddedbase64.decode_base64(
self.push_attempts[0][2]["notification"]["mac"]
)
ciphertext = unpaddedbase64.decode_base64(
self.push_attempts[0][2]["notification"]["ciphertext"]
)

# do the exchange
exchanged = PrivateKey.load(
unpaddedbase64.decode_base64(private_key)
).do_exchange(PublicKey(ephemeral))
# expand with HKDF
zerosalt = bytes([0] * 32)
prk = hmac.new(zerosalt, exchanged, hashlib.sha256).digest()
aes_key = hmac.new(prk, bytes([1]), hashlib.sha256).digest()
mac_key = hmac.new(prk, aes_key + bytes([2]), hashlib.sha256).digest()
aes_iv = hmac.new(prk, mac_key + bytes([3]), hashlib.sha256).digest()[0:16]
# create the cleartext with AES-CBC-256
cleartext = json.loads(
unpad(
AES.new(aes_key, AES.MODE_CBC, aes_iv).decrypt(ciphertext),
AES.block_size,
).decode("utf-8")
)
# create the mac
calculated_mac = hmac.new(mac_key, ciphertext, hashlib.sha256).digest()[0:8]

# test if we decrypted everything correctly
self.assertEqual(calculated_mac, mac)
self.assertEqual(cleartext["content"]["body"], "Foxes are cute!")

def test_sends_high_priority_for_encrypted(self):
"""
The HTTP pusher will send pushes at high priority if they correspond
Expand Down