e2e_room_keys.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. # -*- coding: utf-8 -*-
  2. # Copyright 2017, 2018 New Vector Ltd
  3. #
  4. # Licensed under the Apache License, Version 2.0 (the "License");
  5. # you may not use this file except in compliance with the License.
  6. # You may obtain a copy of the License at
  7. #
  8. # http://www.apache.org/licenses/LICENSE-2.0
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. import logging
  16. from six import iteritems
  17. from twisted.internet import defer
  18. from synapse.api.errors import (
  19. Codes,
  20. NotFoundError,
  21. RoomKeysVersionError,
  22. StoreError,
  23. SynapseError,
  24. )
  25. from synapse.util.async_helpers import Linearizer
  26. logger = logging.getLogger(__name__)
  27. class E2eRoomKeysHandler(object):
  28. """
  29. Implements an optional realtime backup mechanism for encrypted E2E megolm room keys.
  30. This gives a way for users to store and recover their megolm keys if they lose all
  31. their clients. It should also extend easily to future room key mechanisms.
  32. The actual payload of the encrypted keys is completely opaque to the handler.
  33. """
  34. def __init__(self, hs):
  35. self.store = hs.get_datastore()
  36. # Used to lock whenever a client is uploading key data. This prevents collisions
  37. # between clients trying to upload the details of a new session, given all
  38. # clients belonging to a user will receive and try to upload a new session at
  39. # roughly the same time. Also used to lock out uploads when the key is being
  40. # changed.
  41. self._upload_linearizer = Linearizer("upload_room_keys_lock")
  42. @defer.inlineCallbacks
  43. def get_room_keys(self, user_id, version, room_id=None, session_id=None):
  44. """Bulk get the E2E room keys for a given backup, optionally filtered to a given
  45. room, or a given session.
  46. See EndToEndRoomKeyStore.get_e2e_room_keys for full details.
  47. Args:
  48. user_id(str): the user whose keys we're getting
  49. version(str): the version ID of the backup we're getting keys from
  50. room_id(string): room ID to get keys for, for None to get keys for all rooms
  51. session_id(string): session ID to get keys for, for None to get keys for all
  52. sessions
  53. Raises:
  54. NotFoundError: if the backup version does not exist
  55. Returns:
  56. A deferred list of dicts giving the session_data and message metadata for
  57. these room keys.
  58. """
  59. # we deliberately take the lock to get keys so that changing the version
  60. # works atomically
  61. with (yield self._upload_linearizer.queue(user_id)):
  62. # make sure the backup version exists
  63. try:
  64. yield self.store.get_e2e_room_keys_version_info(user_id, version)
  65. except StoreError as e:
  66. if e.code == 404:
  67. raise NotFoundError("Unknown backup version")
  68. else:
  69. raise
  70. results = yield self.store.get_e2e_room_keys(
  71. user_id, version, room_id, session_id
  72. )
  73. return results
  74. @defer.inlineCallbacks
  75. def delete_room_keys(self, user_id, version, room_id=None, session_id=None):
  76. """Bulk delete the E2E room keys for a given backup, optionally filtered to a given
  77. room or a given session.
  78. See EndToEndRoomKeyStore.delete_e2e_room_keys for full details.
  79. Args:
  80. user_id(str): the user whose backup we're deleting
  81. version(str): the version ID of the backup we're deleting
  82. room_id(string): room ID to delete keys for, for None to delete keys for all
  83. rooms
  84. session_id(string): session ID to delete keys for, for None to delete keys
  85. for all sessions
  86. Returns:
  87. A deferred of the deletion transaction
  88. """
  89. # lock for consistency with uploading
  90. with (yield self._upload_linearizer.queue(user_id)):
  91. yield self.store.delete_e2e_room_keys(user_id, version, room_id, session_id)
  92. @defer.inlineCallbacks
  93. def upload_room_keys(self, user_id, version, room_keys):
  94. """Bulk upload a list of room keys into a given backup version, asserting
  95. that the given version is the current backup version. room_keys are merged
  96. into the current backup as described in RoomKeysServlet.on_PUT().
  97. Args:
  98. user_id(str): the user whose backup we're setting
  99. version(str): the version ID of the backup we're updating
  100. room_keys(dict): a nested dict describing the room_keys we're setting:
  101. {
  102. "rooms": {
  103. "!abc:matrix.org": {
  104. "sessions": {
  105. "c0ff33": {
  106. "first_message_index": 1,
  107. "forwarded_count": 1,
  108. "is_verified": false,
  109. "session_data": "SSBBTSBBIEZJU0gK"
  110. }
  111. }
  112. }
  113. }
  114. }
  115. Raises:
  116. NotFoundError: if there are no versions defined
  117. RoomKeysVersionError: if the uploaded version is not the current version
  118. """
  119. # TODO: Validate the JSON to make sure it has the right keys.
  120. # XXX: perhaps we should use a finer grained lock here?
  121. with (yield self._upload_linearizer.queue(user_id)):
  122. # Check that the version we're trying to upload is the current version
  123. try:
  124. version_info = yield self.store.get_e2e_room_keys_version_info(user_id)
  125. except StoreError as e:
  126. if e.code == 404:
  127. raise NotFoundError("Version '%s' not found" % (version,))
  128. else:
  129. raise
  130. if version_info["version"] != version:
  131. # Check that the version we're trying to upload actually exists
  132. try:
  133. version_info = yield self.store.get_e2e_room_keys_version_info(
  134. user_id, version
  135. )
  136. # if we get this far, the version must exist
  137. raise RoomKeysVersionError(current_version=version_info["version"])
  138. except StoreError as e:
  139. if e.code == 404:
  140. raise NotFoundError("Version '%s' not found" % (version,))
  141. else:
  142. raise
  143. # go through the room_keys.
  144. # XXX: this should/could be done concurrently, given we're in a lock.
  145. for room_id, room in iteritems(room_keys["rooms"]):
  146. for session_id, session in iteritems(room["sessions"]):
  147. yield self._upload_room_key(
  148. user_id, version, room_id, session_id, session
  149. )
  150. @defer.inlineCallbacks
  151. def _upload_room_key(self, user_id, version, room_id, session_id, room_key):
  152. """Upload a given room_key for a given room and session into a given
  153. version of the backup. Merges the key with any which might already exist.
  154. Args:
  155. user_id(str): the user whose backup we're setting
  156. version(str): the version ID of the backup we're updating
  157. room_id(str): the ID of the room whose keys we're setting
  158. session_id(str): the session whose room_key we're setting
  159. room_key(dict): the room_key being set
  160. """
  161. # get the room_key for this particular row
  162. current_room_key = None
  163. try:
  164. current_room_key = yield self.store.get_e2e_room_key(
  165. user_id, version, room_id, session_id
  166. )
  167. except StoreError as e:
  168. if e.code == 404:
  169. pass
  170. else:
  171. raise
  172. if self._should_replace_room_key(current_room_key, room_key):
  173. yield self.store.set_e2e_room_key(
  174. user_id, version, room_id, session_id, room_key
  175. )
  176. @staticmethod
  177. def _should_replace_room_key(current_room_key, room_key):
  178. """
  179. Determine whether to replace a given current_room_key (if any)
  180. with a newly uploaded room_key backup
  181. Args:
  182. current_room_key (dict): Optional, the current room_key dict if any
  183. room_key (dict): The new room_key dict which may or may not be fit to
  184. replace the current_room_key
  185. Returns:
  186. True if current_room_key should be replaced by room_key in the backup
  187. """
  188. if current_room_key:
  189. # spelt out with if/elifs rather than nested boolean expressions
  190. # purely for legibility.
  191. if room_key["is_verified"] and not current_room_key["is_verified"]:
  192. return True
  193. elif (
  194. room_key["first_message_index"]
  195. < current_room_key["first_message_index"]
  196. ):
  197. return True
  198. elif room_key["forwarded_count"] < current_room_key["forwarded_count"]:
  199. return True
  200. else:
  201. return False
  202. return True
  203. @defer.inlineCallbacks
  204. def create_version(self, user_id, version_info):
  205. """Create a new backup version. This automatically becomes the new
  206. backup version for the user's keys; previous backups will no longer be
  207. writeable to.
  208. Args:
  209. user_id(str): the user whose backup version we're creating
  210. version_info(dict): metadata about the new version being created
  211. {
  212. "algorithm": "m.megolm_backup.v1",
  213. "auth_data": "dGhpcyBzaG91bGQgYWN0dWFsbHkgYmUgZW5jcnlwdGVkIGpzb24K"
  214. }
  215. Returns:
  216. A deferred of a string that gives the new version number.
  217. """
  218. # TODO: Validate the JSON to make sure it has the right keys.
  219. # lock everyone out until we've switched version
  220. with (yield self._upload_linearizer.queue(user_id)):
  221. new_version = yield self.store.create_e2e_room_keys_version(
  222. user_id, version_info
  223. )
  224. return new_version
  225. @defer.inlineCallbacks
  226. def get_version_info(self, user_id, version=None):
  227. """Get the info about a given version of the user's backup
  228. Args:
  229. user_id(str): the user whose current backup version we're querying
  230. version(str): Optional; if None gives the most recent version
  231. otherwise a historical one.
  232. Raises:
  233. NotFoundError: if the requested backup version doesn't exist
  234. Returns:
  235. A deferred of a info dict that gives the info about the new version.
  236. {
  237. "version": "1234",
  238. "algorithm": "m.megolm_backup.v1",
  239. "auth_data": "dGhpcyBzaG91bGQgYWN0dWFsbHkgYmUgZW5jcnlwdGVkIGpzb24K"
  240. }
  241. """
  242. with (yield self._upload_linearizer.queue(user_id)):
  243. try:
  244. res = yield self.store.get_e2e_room_keys_version_info(user_id, version)
  245. except StoreError as e:
  246. if e.code == 404:
  247. raise NotFoundError("Unknown backup version")
  248. else:
  249. raise
  250. return res
  251. @defer.inlineCallbacks
  252. def delete_version(self, user_id, version=None):
  253. """Deletes a given version of the user's e2e_room_keys backup
  254. Args:
  255. user_id(str): the user whose current backup version we're deleting
  256. version(str): the version id of the backup being deleted
  257. Raises:
  258. NotFoundError: if this backup version doesn't exist
  259. """
  260. with (yield self._upload_linearizer.queue(user_id)):
  261. try:
  262. yield self.store.delete_e2e_room_keys_version(user_id, version)
  263. except StoreError as e:
  264. if e.code == 404:
  265. raise NotFoundError("Unknown backup version")
  266. else:
  267. raise
  268. @defer.inlineCallbacks
  269. def update_version(self, user_id, version, version_info):
  270. """Update the info about a given version of the user's backup
  271. Args:
  272. user_id(str): the user whose current backup version we're updating
  273. version(str): the backup version we're updating
  274. version_info(dict): the new information about the backup
  275. Raises:
  276. NotFoundError: if the requested backup version doesn't exist
  277. Returns:
  278. A deferred of an empty dict.
  279. """
  280. if "version" not in version_info:
  281. raise SynapseError(400, "Missing version in body", Codes.MISSING_PARAM)
  282. if version_info["version"] != version:
  283. raise SynapseError(
  284. 400, "Version in body does not match", Codes.INVALID_PARAM
  285. )
  286. with (yield self._upload_linearizer.queue(user_id)):
  287. try:
  288. old_info = yield self.store.get_e2e_room_keys_version_info(
  289. user_id, version
  290. )
  291. except StoreError as e:
  292. if e.code == 404:
  293. raise NotFoundError("Unknown backup version")
  294. else:
  295. raise
  296. if old_info["algorithm"] != version_info["algorithm"]:
  297. raise SynapseError(400, "Algorithm does not match", Codes.INVALID_PARAM)
  298. yield self.store.update_e2e_room_keys_version(
  299. user_id, version, version_info
  300. )
  301. return {}