Skip to content
This repository was 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
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
3 changes: 3 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -327,3 +327,6 @@ ignore_missing_imports = True

[mypy-zope]
ignore_missing_imports = True

[mypy-donna25519]
ignore_missing_imports = True
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
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":
base64_public_key = self.data.get("public_key")
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]:
"""Called to process a payload according to the algorithm the pusher is
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)

# 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)
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]
# create the ciphertext with AES-CBC-256
ciphertext = AES.new(aes_key, AES.MODE_CBC, aes_iv).encrypt(
pad(cleartext.encode("utf-8"), AES.block_size)
)
# create the mac
mac = hmac.new(mac_key, ciphertext, hashlib.sha256).digest()[0:8]

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",
"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

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