Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 
 
 
 
 

434 linhas
17 KiB

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