e2e_room_keys.py 16 KB

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