-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Add encrypted http pusher (MSC3013) #11512
base: develop
Are you sure you want to change the base?
Changes from all commits
848aed2
d4833c2
251f304
bbf3baa
e129b01
ba1d216
2f32b28
d38ed73
4e75838
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Add support for MSC3013 Encrypted Push. Contributed by Famedly GmbH / Sorunome. |
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 cryptography.hazmat.primitives import padding | ||||||||
from cryptography.hazmat.primitives.asymmetric.x25519 import ( | ||||||||
X25519PrivateKey, | ||||||||
X25519PublicKey, | ||||||||
) | ||||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes | ||||||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat | ||||||||
from prometheus_client import Counter | ||||||||
|
||||||||
from twisted.internet.error import AlreadyCalled, AlreadyCancelled | ||||||||
|
@@ -106,9 +119,118 @@ 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 self.hs.config.experimental.msc3013_enabled: | ||||||||
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" | ||||||||
) | ||||||||
|
||||||||
if "counts_only_type" not in self.data: | ||||||||
self.counts_only_type = "none" | ||||||||
elif self.data["counts_only_type"] not in ("none", "boolean", "full"): | ||||||||
raise PusherConfigException( | ||||||||
"'counts_only_type' must be one of 'none', 'boolean' or 'full'" | ||||||||
) | ||||||||
else: | ||||||||
self.counts_only_type = self.data["counts_only_type"] | ||||||||
del self.sanitized_data["counts_only_type"] | ||||||||
|
||||||||
del self.sanitized_data["public_key"] | ||||||||
|
||||||||
def _encrypt_notification_dict( | ||||||||
self, payload: Dict[str, Any], counts_only: bool = False | ||||||||
) -> 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.hs.config.experimental.msc3013_enabled | ||||||||
and 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 | ||||||||
encryptor = Cipher(algorithms.AES(aes_key), modes.CBC(aes_iv)).encryptor() | ||||||||
# AES blocksize is always 128 bits | ||||||||
padder = padding.PKCS7(128).padder() | ||||||||
ciphertext = ( | ||||||||
encryptor.update( | ||||||||
padder.update(cleartext.encode("utf-8")) + padder.finalize() | ||||||||
) | ||||||||
+ encryptor.finalize() | ||||||||
) | ||||||||
# create the mac | ||||||||
mac = hmac.new(mac_key, ciphertext, hashlib.sha256).digest()[0:8] | ||||||||
|
||||||||
d = { | ||||||||
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. Can we have a more descriptive name than |
||||||||
"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, | ||||||||
} | ||||||||
|
||||||||
# now, if the push frame is a counts only frame, we have to respect the counts_only_type setting | ||||||||
if counts_only: | ||||||||
if self.counts_only_type == "boolean": | ||||||||
d["is_counts_only"] = True | ||||||||
elif self.counts_only_type == "full": | ||||||||
d["counts"] = cleartext_notif["counts"] | ||||||||
Comment on lines
+221
to
+226
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. I'm not sure this logic belongs in a function called Put differently: why is this tied to the use of 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. See the MSC https://github.com/Sorunome/matrix-doc/blob/soru/encrypted-push/proposals/3013-encrypted-push.md#ios Why 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. so maybe call the method |
||||||||
|
||||||||
return { | ||||||||
"notification": d, | ||||||||
} | ||||||||
|
||||||||
# 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 +455,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 +481,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 +500,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 +532,25 @@ 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, | ||||||||
} | ||||||||
], | ||||||||
} | ||||||||
}, | ||||||||
counts_only=True, | ||||||||
) | ||||||||
try: | ||||||||
await self.http_client.post_json_get_json(self.url, d) | ||||||||
http_badges_processed_counter.inc() | ||||||||
|
Uh oh!
There was an error while loading. Please reload this page.