This repository was archived by the owner on Apr 26, 2024. It is now read-only.
-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Add encrypted http pusher (MSC3013) #11512
Open
Sorunome
wants to merge
9
commits into
matrix-org:develop
Choose a base branch
from
famedly:soru/encrypted-push
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 4 commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
848aed2
Add encrypted http pusher
Sorunome d4833c2
properly type-ignore donna25519 and fix an absent public_key
Sorunome 251f304
use cryptography in favour of donna25519
Sorunome bbf3baa
rename _process_notification_dict to _encrypt_notification_dict
Sorunome e129b01
remove dependency of pynacl
Sorunome ba1d216
Hide encrypted push behind a feature flag
Sorunome 2f32b28
forgot to advertise encrypted push properly
Sorunome d38ed73
add handeling of counts_only_type pusher data
Sorunome 4e75838
Merge branch 'develop' of https://github.com/matrix-org/synapse into …
Sorunome File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Add support for MSC3013 Encrypted Push. Contributed by Famedly GmbH / Sorunome. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. | ||||||||
|
@@ -12,10 +14,21 @@ | |||||||
# 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 cryptography.hazmat.primitives.asymmetric.x25519 import ( | ||||||||
X25519PrivateKey, | ||||||||
X25519PublicKey, | ||||||||
) | ||||||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat | ||||||||
from prometheus_client import Counter | ||||||||
|
||||||||
from twisted.internet.error import AlreadyCalled, AlreadyCancelled | ||||||||
|
@@ -106,9 +119,86 @@ 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 = X25519PublicKey.from_public_bytes( | ||||||||
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 _encrypt_notification_dict(self, payload: Dict[str, Any]) -> Dict[str, Any]: | ||||||||
"""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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could use
Suggested change
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 = X25519PrivateKey.generate() | ||||||||
# do ECDH | ||||||||
secret_key = private_key.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] | ||||||||
|
||||||||
return { | ||||||||
"notification": { | ||||||||
"ephemeral": unpaddedbase64.encode_base64( | ||||||||
private_key.public_key().public_bytes( | ||||||||
Encoding.Raw, PublicFormat.Raw | ||||||||
) | ||||||||
), | ||||||||
"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. | ||||||||
|
@@ -333,12 +423,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._encrypt_notification_dict(d) | ||||||||
|
||||||||
ctx = await push_tools.get_context_for_event(self.storage, event, self.user_id) | ||||||||
|
||||||||
|
@@ -359,7 +449,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, | ||||||||
} | ||||||||
], | ||||||||
|
@@ -378,7 +468,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._encrypt_notification_dict(d) | ||||||||
|
||||||||
async def dispatch_push( | ||||||||
self, event: EventBase, tweaks: Dict[str, bool], badge: int | ||||||||
|
@@ -410,22 +500,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._encrypt_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() | ||||||||
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,8 +11,19 @@ | |
# 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 cryptography.hazmat.primitives.asymmetric.x25519 import ( | ||
X25519PrivateKey, | ||
X25519PublicKey, | ||
) | ||
|
||
from twisted.internet.defer import Deferred | ||
|
||
import synapse.rest.admin | ||
|
@@ -198,6 +209,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 = X25519PrivateKey.from_private_bytes( | ||
unpaddedbase64.decode_base64(private_key) | ||
).exchange(X25519PublicKey.from_public_bytes(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 | ||
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.