e2e_room_keys.py 16 KB

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