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
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 synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ def read_config(self, config: JsonDict, **kwargs):
# MSC3266 (room summary api)
self.msc3266_enabled: bool = experimental.get("msc3266_enabled", False)

# MSC3013 (encrypted push)
self.msc3013_enabled: bool = experimental.get("msc3013_enabled", False)

# MSC3030 (Jump to date API endpoint)
self.msc3030_enabled: bool = experimental.get("msc3030_enabled", False)

Expand Down
171 changes: 148 additions & 23 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,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
Expand Down Expand Up @@ -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
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 = X25519PrivateKey.generate()
# do ECDH
secret_key = private_key.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
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 = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we have a more descriptive name than d here? encrypted_notification or payload_with_encryption?

"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
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure this logic belongs in a function called _encrypt_notification_dict---this isn't doing any encryption. I take the point that this is needed to make iOS E2EE work, but doesn't this setting make sense even if we don't want to use encryption?

Put differently: why is this tied to the use of curve25519-aes-sha2 specifically?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 curve25519-aes-sha2 specifically? Because only plain and curve25519-aes-sha2 exist in this PR, and it is not in plain

Copy link
Contributor Author

Choose a reason for hiding this comment

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

so maybe call the method _process_notification_dict_for_sending instead?


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.
Expand Down Expand Up @@ -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)

Expand All @@ -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,
}
],
Expand All @@ -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
Expand Down Expand Up @@ -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()
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 @@ -95,6 +95,8 @@ def on_GET(self, request: Request) -> Tuple[int, JsonDict]:
"org.matrix.msc2285": self.config.experimental.msc2285_enabled,
# Adds support for importing historical messages as per MSC2716
"org.matrix.msc2716": self.config.experimental.msc2716_enabled,
# Adds support for encrypted push as per MSC3013
"com.famedly.msc3013": self.config.experimental.msc3013_enabled,
# Adds support for jump to date endpoints (/timestamp_to_event) as per MSC3030
"org.matrix.msc3030": self.config.experimental.msc3030_enabled,
},
Expand Down
Loading