Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
35 changes: 30 additions & 5 deletions matrix_client/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,13 +164,23 @@ def logout(self):
"""
return self._send("POST", "/logout")

def create_room(self, alias=None, is_public=False, invitees=None):
def create_room(
self,
alias=None,
name=None,
is_public=False,
invitees=None,
federate=None
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't like leaving booleans as None. You should set this to true, so that users of the API know that federation is on by default. I'd actually argue None is closer to False, which is obviously dangerously wrong.

https://matrix.org/docs/spec/client_server/unstable.html#m-room-create

Copy link
Author

Choose a reason for hiding this comment

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

On my homeservet it's off by default...
I prefer to give a choice of three options: explicitly turn on, explicitly turn off and leave by default.

Copy link
Collaborator

Choose a reason for hiding this comment

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

The default given in the spec is True. If your homeserver does something different then it's non-conforming?

Copy link
Author

Choose a reason for hiding this comment

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

We discuss about SDK implementation or about settings on my homeserver? I gave an example to show that the behavior of the server is not always obvious and it is useful to give the developer the opportunity to explicitly set this parameter.

Copy link
Collaborator

Choose a reason for hiding this comment

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

We should specify the matrix spec default in the methods docstring. None is fine as default kwarg; we just need to be explicit that according to matrix spec this defaults to True in the docstring. If servers have a setting that can change the default value of this and that's considered part of the spec, it's a spec bug that the setting isn't mentioned at the place @Half-Shot linked.

Copy link
Collaborator

@non-Jedi non-Jedi Jul 13, 2018

Choose a reason for hiding this comment

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

So based on a conversation with @ara4n in #matrix-spec:matrix.org, a server that had a default value other than True for room federation would be non-compliant with the matrix spec. I'm fine with including this distinction between None and False if it's useful to you, but the docstring for this method needs to explicitly say that True is the default.

https://matrix.to/#/!YkZelGRiqijtzXZODa:matrix.org/$1531495739346932GaxTh:matrix.org

):
"""Perform /createRoom.

Args:
alias (str): Optional. The room alias name to set for this room.
name (str): Optional. Name for new room.
is_public (bool): Optional. The public/private visibility.
invitees (list<str>): Optional. The list of user IDs to invite.
federate (bool): Optional. Сan a room be federated.
Copy link
Collaborator

@non-Jedi non-Jedi Jul 16, 2018

Choose a reason for hiding this comment

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

Please include that this defaults to True.

Copy link
Author

Choose a reason for hiding this comment

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

I already explained for Half-Shot why I left three options:
#245 (comment)

Copy link
Author

Choose a reason for hiding this comment

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

@non-Jedi included.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Sorry to be anal about this, but the spec says that homeservers default to true on that parameter if it isn't included. It's not a synapse-specific behavior, and we need to document the behavior of a spec-compliant HS. So something like: "Defaults to True if not specified" is what I'm after here.

Copy link
Author

Choose a reason for hiding this comment

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

That's better?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yep. That's fine. Thanks @slipeer. I'll merge when I'm at a computer I've set up with ssh to my github profile.

Default to True.
"""
content = {
"visibility": "public" if is_public else "private"
Expand All @@ -179,6 +189,10 @@ def create_room(self, alias=None, is_public=False, invitees=None):
content["room_alias_name"] = alias
if invitees:
content["invite"] = invitees
if name:
content["name"] = name
if federate is not None:
Copy link
Collaborator

Choose a reason for hiding this comment

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

wrt above comment: if federate is False

Copy link
Author

Choose a reason for hiding this comment

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

feredate can be True, False or None. If it's None we do not add it to json.

content["creation_content"] = {'m.federate': federate}
return self._send("POST", "/createRoom", content)

def join_room(self, room_id_or_alias):
Expand Down Expand Up @@ -233,6 +247,18 @@ def send_state_event(self, room_id, event_type, content, state_key="",
params["ts"] = timestamp
return self._send("PUT", path, content, query_params=params)

def get_state_event(self, room_id, event_type):
"""Perform GET /rooms/$room_id/state/$event_type

Args:
room_id(str): The room ID.
event_type (str): The type of the event.

Raises:
MatrixRequestError(code=404) if the state event is not found.
"""
return self._send("GET", "/rooms/{}/state/{}".format(quote(room_id), event_type))

def send_message_event(self, room_id, event_type, content, txn_id=None,
timestamp=None):
"""Perform PUT /rooms/$room_id/send/$event_type
Expand Down Expand Up @@ -393,7 +419,7 @@ def get_room_name(self, room_id):
Args:
room_id(str): The room ID
"""
return self._send("GET", "/rooms/" + room_id + "/state/m.room.name")
return self.get_state_event(room_id, "m.room.name")

def set_room_name(self, room_id, name, timestamp=None):
"""Perform PUT /rooms/$room_id/state/m.room.name
Expand All @@ -412,7 +438,7 @@ def get_room_topic(self, room_id):
Args:
room_id (str): The room ID
"""
return self._send("GET", "/rooms/" + room_id + "/state/m.room.topic")
return self.get_state_event(room_id, "m.room.topic")

def set_room_topic(self, room_id, topic, timestamp=None):
"""Perform PUT /rooms/$room_id/state/m.room.topic
Expand All @@ -432,8 +458,7 @@ def get_power_levels(self, room_id):
Args:
room_id(str): The room ID
"""
return self._send("GET", "/rooms/" + quote(room_id) +
"/state/m.room.power_levels")
return self.get_state_event(room_id, "m.room.power_levels")

def set_power_levels(self, room_id, content):
"""Perform PUT /rooms/$room_id/state/m.room.power_levels
Expand Down
65 changes: 42 additions & 23 deletions matrix_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
from .errors import MatrixRequestError, MatrixUnexpectedResponse
from .room import Room
from .user import User
try:
from .crypto.olm_device import OlmDevice
ENCRYPTION_SUPPORT = True
except ImportError:
ENCRYPTION_SUPPORT = False
from threading import Thread
from time import sleep
from uuid import uuid4
Expand Down Expand Up @@ -54,6 +59,13 @@ class MatrixClient(object):
the token) if supplying a token; otherwise, ignored.
valid_cert_check (bool): Check the homeservers
certificate on connections?
cache_level (CACHE): One of CACHE.NONE, CACHE.SOME, or
CACHE.ALL (defined in module namespace).
encryption (bool): Optional. Whether or not to enable end-to-end encryption
support.
encryption_conf (dict): Optional. Configuration parameters for encryption.
Refer to :func:`~matrix_client.crypto.olm_device.OlmDevice` for supported
options, since it will be passed to this class.

Returns:
`MatrixClient`
Expand Down Expand Up @@ -95,30 +107,12 @@ def global_callback(incoming_event):

def __init__(self, base_url, token=None, user_id=None,
valid_cert_check=True, sync_filter_limit=20,
cache_level=CACHE.ALL):
""" Create a new Matrix Client object.

Args:
base_url (str): The url of the HS preceding /_matrix.
e.g. (ex: https://localhost:8008 )
token (str): Optional. If you have an access token
supply it here.
user_id (str): Optional. You must supply the user_id
(as obtained when initially logging in to obtain
the token) if supplying a token; otherwise, ignored.
valid_cert_check (bool): Check the homeservers
certificate on connections?
cache_level (CACHE): One of CACHE.NONE, CACHE.SOME, or
CACHE.ALL (defined in module namespace).

Returns:
MatrixClient

Raises:
MatrixRequestError, ValueError
"""
cache_level=CACHE.ALL, encryption=False, encryption_conf=None):
if token is not None and user_id is None:
raise ValueError("must supply user_id along with token")
if encryption and not ENCRYPTION_SUPPORT:
raise ValueError("Failed to enable encryption. Please make sure the olm "
"library is available.")

self.api = MatrixHttpApi(base_url, token)
self.api.validate_certificate(valid_cert_check)
Expand All @@ -127,6 +121,10 @@ def __init__(self, base_url, token=None, user_id=None,
self.invite_listeners = []
self.left_listeners = []
self.ephemeral_listeners = []
self.device_id = None
self._encryption = encryption
self.encryption_conf = encryption_conf or {}
self.olm_device = None
if isinstance(cache_level, CACHE):
self._cache_level = cache_level
else:
Expand Down Expand Up @@ -273,6 +271,13 @@ def login(self, username, password, limit=10, sync=True, device_id=None):
self.token = response["access_token"]
self.hs = response["home_server"]
self.api.token = self.token
self.device_id = response["device_id"]

if self._encryption:
self.olm_device = OlmDevice(
self.api, self.user_id, self.device_id, **self.encryption_conf)
self.olm_device.upload_identity_keys()
self.olm_device.upload_one_time_keys()

if sync:
""" Limit Filter """
Expand Down Expand Up @@ -548,9 +553,19 @@ def upload(self, content, content_type):
)

def _mkroom(self, room_id):
self.rooms[room_id] = Room(self, room_id)
room = Room(self, room_id)
if self._encryption:
try:
event = self.api.get_state_event(room_id, "m.room.encryption")
if event["algorithm"] == "m.megolm.v1.aes-sha2":
room.encrypted = True
except MatrixRequestError as e:
if e.code != 404:
raise
self.rooms[room_id] = room
return self.rooms[room_id]

# TODO better handling of the blocking I/O caused by update_one_time_key_counts
def _sync(self, timeout_ms=30000):
response = self.api.sync(self.sync_token, timeout_ms, filter=self.sync_filter)
self.sync_token = response["next_batch"]
Expand All @@ -569,6 +584,10 @@ def _sync(self, timeout_ms=30000):
if room_id in self.rooms:
del self.rooms[room_id]

if self._encryption and 'device_one_time_keys_count' in response:
self.olm_device.update_one_time_key_counts(
response['device_one_time_keys_count'])

for room_id, sync_room in response['rooms']['join'].items():
if room_id not in self.rooms:
self._mkroom(room_id)
Expand Down
111 changes: 110 additions & 1 deletion matrix_client/crypto/olm_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from canonicaljson import encode_canonical_json

from matrix_client.checks import check_user_id
from matrix_client.crypto.one_time_keys import OneTimeKeysManager

logger = logging.getLogger(__name__)

Expand All @@ -17,15 +18,123 @@ class OlmDevice(object):
api (MatrixHttpApi): The api object used to make requests.
user_id (str): Matrix user ID. Must match the one used when logging in.
device_id (str): Must match the one used when logging in.
signed_keys_proportion (float): Optional. The proportion of signed one-time keys
we should maintain on the HS compared to unsigned keys. The maximum value of
``1`` means only signed keys will be uploaded, while the minimum value of
``0`` means only unsigned keys. The actual amount of keys is determined at
runtime from the given proportion and the maximum number of one-time keys
we can physically hold.
keys_threshold (float): Optional. Threshold below which a one-time key
replenishment is triggered. Must be between ``0`` and ``1``. For example,
``0.1`` means that new one-time keys will be uploaded when there is less than
10% of the maximum number of one-time keys on the server.
"""

def __init__(self, api, user_id, device_id):
_olm_algorithm = 'm.olm.v1.curve25519-aes-sha2'
_megolm_algorithm = 'm.megolm.v1.aes-sha2'
_algorithms = [_olm_algorithm, _megolm_algorithm]

def __init__(self,
api,
user_id,
device_id,
signed_keys_proportion=1,
keys_threshold=0.1):
if not 0 <= signed_keys_proportion <= 1:
raise ValueError('signed_keys_proportion must be between 0 and 1.')
if not 0 <= keys_threshold <= 1:
raise ValueError('keys_threshold must be between 0 and 1.')
self.api = api
check_user_id(user_id)
self.user_id = user_id
self.device_id = device_id
self.olm_account = olm.Account()
logger.info('Initialised Olm Device.')
self.identity_keys = self.olm_account.identity_keys
# Try to maintain half the number of one-time keys libolm can hold uploaded
# on the HS. This is because some keys will be claimed by peers but not
# used instantly, and we want them to stay in libolm, until the limit is reached
# and it starts discarding keys, starting by the oldest.
target_keys_number = self.olm_account.max_one_time_keys // 2
self.one_time_keys_manager = OneTimeKeysManager(target_keys_number,
signed_keys_proportion,
keys_threshold)

def upload_identity_keys(self):
"""Uploads this device's identity keys to HS.

This device must be the one used when logging in.
"""
device_keys = {
'user_id': self.user_id,
'device_id': self.device_id,
'algorithms': self._algorithms,
'keys': {'{}:{}'.format(alg, self.device_id): key
for alg, key in self.identity_keys.items()}
}
self.sign_json(device_keys)
ret = self.api.upload_keys(device_keys=device_keys)
self.one_time_keys_manager.server_counts = ret['one_time_key_counts']
logger.info('Uploaded identity keys.')

def upload_one_time_keys(self, force_update=False):
"""Uploads new one-time keys to the HS, if needed.

Args:
force_update (bool): Fetch the number of one-time keys currently on the HS
before uploading, even if we already know one. In most cases this should
not be necessary, as we get this value from sync responses.

Returns:
A dict containg the number of new keys that were uploaded for each key type
(signed_curve25519 or curve25519). The format is
``<key_type>: <uploaded_number>``. If no keys of a given type have been
uploaded, the corresponding key will not be present. Consequently, an
empty dict indicates that no keys were uploaded.
"""
if force_update or not self.one_time_keys_manager.server_counts:
counts = self.api.upload_keys()['one_time_key_counts']
self.one_time_keys_manager.server_counts = counts

signed_keys_to_upload = self.one_time_keys_manager.signed_curve25519_to_upload
unsigned_keys_to_upload = self.one_time_keys_manager.curve25519_to_upload

self.olm_account.generate_one_time_keys(signed_keys_to_upload +
unsigned_keys_to_upload)

one_time_keys = {}
keys = self.olm_account.one_time_keys['curve25519']
for i, key_id in enumerate(keys):
if i < signed_keys_to_upload:
key = self.sign_json({'key': keys[key_id]})
key_type = 'signed_curve25519'
else:
key = keys[key_id]
key_type = 'curve25519'
one_time_keys['{}:{}'.format(key_type, key_id)] = key

ret = self.api.upload_keys(one_time_keys=one_time_keys)
self.one_time_keys_manager.server_counts = ret['one_time_key_counts']
self.olm_account.mark_keys_as_published()

keys_uploaded = {}
if unsigned_keys_to_upload:
keys_uploaded['curve25519'] = unsigned_keys_to_upload
if signed_keys_to_upload:
keys_uploaded['signed_curve25519'] = signed_keys_to_upload
logger.info('Uploaded new one-time keys: %s.', keys_uploaded)
return keys_uploaded

def update_one_time_key_counts(self, counts):
"""Update data on one-time keys count and upload new ones if necessary.

Args:
counts (dict): Counts of keys currently on the HS for each key type.
"""
self.one_time_keys_manager.server_counts = counts
if self.one_time_keys_manager.should_upload():
logger.info('Uploading new one-time keys.')
self.upload_one_time_keys()

def sign_json(self, json):
"""Signs a JSON object.
Expand Down
42 changes: 42 additions & 0 deletions matrix_client/crypto/one_time_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
class OneTimeKeysManager(object):
"""Handles one-time keys accounting for an OlmDevice."""

def __init__(self, target_keys_number, signed_keys_proportion, keys_threshold):
self.target_counts = {
'signed_curve25519': int(round(signed_keys_proportion * target_keys_number)),
'curve25519': int(round((1 - signed_keys_proportion) * target_keys_number)),
}
self._server_counts = {}
self.to_upload = {}
self.keys_threshold = keys_threshold

@property
def server_counts(self):
return self._server_counts

@server_counts.setter
def server_counts(self, server_counts):
self._server_counts = server_counts
self.update_keys_to_upload()

def update_keys_to_upload(self):
for key_type, target_number in self.target_counts.items():
num_keys = self._server_counts.get(key_type, 0)
num_to_create = max(target_number - num_keys, 0)
self.to_upload[key_type] = num_to_create

def should_upload(self):
if not self._server_counts:
return True
for key_type, target_number in self.target_counts.items():
if self._server_counts.get(key_type, 0) < target_number * self.keys_threshold:
return True
return False

@property
def curve25519_to_upload(self):
return self.to_upload.get('curve25519', 0)

@property
def signed_curve25519_to_upload(self):
return self.to_upload.get('signed_curve25519', 0)
Loading