Skip to main content

arminius_lib_db_keys/
room_keys.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Queries related to room keys backups.
6//!
7//! There are two tables for these backups:
8//! - `room_keys_versions`: Contains the backup declarations.
9//! - `room_keys`: Contains the actual keys, linked to the `room_keys_versions` entry they belong
10//!   to.
11
12use std::collections::BTreeMap;
13
14use arminius_lib_db::{Database, DatabaseError, ResultExt as _, TransactedDb};
15use ruma::{OwnedRoomId, RoomId, UserId, api::client::backup::KeyBackupData};
16
17/// The information about a stored backup.
18#[derive(Debug)]
19pub struct DbRoomKeysBackup {
20	/// Algorithm-dependent data about the backup.
21	pub auth_data: serde_json::Value,
22	/// Number of keys stored in the backup.
23	pub keys_count: u64,
24	/// Integer representing the backup content's version.
25	pub etag: i64,
26	/// Version of the backup.
27	pub version: i64,
28	/// The actual keys stored in the backup.
29	pub rooms: BTreeMap<OwnedRoomId, BTreeMap<String, KeyBackupData>>,
30}
31
32/// Queries related to room keys tables.
33// Queries are usually used as extension traits so suggest `import as _`.
34#[rust_analyzer::prefer_underscore_import]
35pub trait RoomKeysQueries: Database {
36	/// Creates a new room keys backup in the database and return its version as
37	/// a string.
38	///
39	/// Note that the version will be set to `1` by default.
40	#[tracing::instrument(skip(self))]
41	async fn insert_backup(
42		&mut self,
43		user_id: &UserId,
44		auth_data: &serde_json::Value,
45	) -> Result<String, DatabaseError> {
46		Ok(sqlx::query!(
47			"
48			WITH new_backup_version AS (
49				UPDATE users SET keys_backup_seq = keys_backup_seq + 1
50				WHERE user_id = $1
51				RETURNING nid, keys_backup_seq
52			)
53			INSERT INTO room_keys_versions (user_nid, version, auth_data)
54			SELECT nid, keys_backup_seq, $2 FROM new_backup_version
55			RETURNING version
56			",
57			user_id.to_string(),
58			auth_data
59		)
60		.fetch_one(self.connection().await?)
61		.await?
62		.version
63		.to_string())
64	}
65
66	/// Updates a specific user's backup version.
67	///
68	/// Errors if the specified backup does not exist.
69	#[tracing::instrument(skip(self))]
70	async fn update_user_backup(
71		&mut self,
72		user_id: &UserId,
73		version: i64,
74		new_auth_data: &serde_json::Value,
75	) -> Result<(), DatabaseError> {
76		if sqlx::query!(
77			r#"
78			UPDATE
79				room_keys_versions
80			SET
81				auth_data = $3,
82				etag = etag + 1
83			FROM
84				users
85			WHERE
86				room_keys_versions.user_nid = users.nid
87				AND users.user_id = $1
88				AND room_keys_versions.version = $2
89			"#,
90			user_id.to_string(),
91			version,
92			new_auth_data
93		)
94		.execute(self.connection().await?)
95		.await?
96		.rows_affected()
97			!= 1
98		{
99			return Err(DatabaseError::SqlxError(sqlx::Error::RowNotFound));
100		}
101
102		Ok(())
103	}
104
105	/// Fetches the `keys_backup_seq` of an user. This value is the highest room
106	/// keys backup version ever created for that user.
107	#[tracing::instrument(skip(self))]
108	async fn fetch_max_user_backup_version(
109		&mut self,
110		user_id: &UserId,
111	) -> Result<i64, DatabaseError> {
112		Ok(sqlx::query_scalar!(
113			"SELECT keys_backup_seq FROM users WHERE user_id = $1",
114			user_id.to_string()
115		)
116		.fetch_one(self.connection().await?)
117		.await?)
118	}
119
120	/// Deletes a specific user's backup version.
121	#[tracing::instrument(skip(self))]
122	async fn delete_user_backup(
123		&mut self,
124		user_id: &UserId,
125		version: i64,
126	) -> Result<(), DatabaseError> {
127		sqlx::query!(
128			"
129			DELETE FROM room_keys_versions
130			USING users
131			WHERE
132				room_keys_versions.user_nid = users.nid
133				AND users.user_id = $1
134				AND room_keys_versions.version = $2
135			",
136			user_id.to_string(),
137			version
138		)
139		.execute(self.connection().await?)
140		.await?;
141
142		Ok(())
143	}
144
145	/// Deletes `room_keys` entries belonging to the given user and backup
146	/// version.
147	///
148	/// If `room_id` and `session_id` are `Some`, the query will use them as
149	/// filters.
150	///
151	/// This returns the new `etag` for the backup, as well as the count of keys
152	/// left in it.
153	#[tracing::instrument(skip(self))]
154	async fn delete_backup_keys(
155		&mut self,
156		user_id: &UserId,
157		version: i64,
158		room_id: Option<OwnedRoomId>,
159		session_id: Option<String>,
160	) -> Result<Option<(i64, u64)>, DatabaseError> {
161		Ok(sqlx::query_file!(
162			"src/delete_backup_keys.sql",
163			user_id.to_string(),
164			version,
165			room_id.map(|room_id| room_id.to_string()),
166			session_id
167		)
168		.fetch_optional(self.connection().await?)
169		.await?
170		.map(|row| (row.etag, row.keys_count as u64)))
171	}
172}
173
174impl<T: Database> RoomKeysQueries for T {}
175
176/// SQL queries related to room keys that must occur in a database transaction.
177pub trait TransactedRoomKeysQueries: RoomKeysQueries {
178	/// Fetches a specific user's backup.
179	///
180	/// If `version` is `Some()`, it will filter using this version, otherwise
181	/// it will take the highest version one (i.e. the latest backup).
182	///
183	/// If `room_id` and/or `session_id` are `Some()`, fetched keys will be
184	/// filtered by these values.
185	#[tracing::instrument(skip(self))]
186	async fn fetch_user_backup(
187		&mut self,
188		user_id: &UserId,
189		version: Option<i64>,
190		room_id: Option<OwnedRoomId>,
191		session_id: Option<String>,
192	) -> Result<Option<DbRoomKeysBackup>, DatabaseError> {
193		let mut backup_and_nid = sqlx::query!(
194			r#"
195			SELECT
196				room_keys_versions.nid,
197				auth_data,
198				etag,
199				version
200			FROM
201				room_keys_versions
202				JOIN users ON room_keys_versions.user_nid = users.nid
203			WHERE
204				users.user_id = $1
205				AND ($2::bigint IS NULL
206					OR version = $2)
207			ORDER BY
208				version DESC
209			LIMIT 1
210			"#,
211			user_id.to_string(),
212			version
213		)
214		.fetch_optional(self.connection().await?)
215		.await?
216		.map(|row| {
217			(
218				row.nid,
219				DbRoomKeysBackup {
220					auth_data: row.auth_data,
221					keys_count: 0,
222					etag: row.etag,
223					version: row.version,
224					rooms: BTreeMap::new(),
225				},
226			)
227		});
228
229		// If we found the backup, fetch all the keys that belong to it
230		if let Some((backup_nid, backup)) = backup_and_nid.as_mut() {
231			let rows = sqlx::query!(
232				r#"
233				SELECT
234					rooms.room_id,
235					session_id,
236					backup_data
237				FROM
238					room_keys
239					JOIN room_keys_versions ON room_keys.room_keys_version_nid = room_keys_versions.nid
240					JOIN users ON room_keys_versions.user_nid = users.nid
241					JOIN rooms ON room_keys.room_nid = rooms.nid
242				WHERE
243					users.user_id = $1
244					AND room_keys_versions.nid = $2
245					AND ($3::text IS NULL
246						OR rooms.room_id = $3)
247					AND ($4::text IS NULL
248						OR room_keys.session_id = $4)
249				"#,
250				user_id.to_string(),
251				*backup_nid,
252				room_id.map(|room_id| room_id.to_string()),
253				session_id
254			)
255			.fetch_all(self.connection().await?)
256			.await?;
257
258			backup.keys_count = rows.len() as u64;
259
260			for row in rows {
261				let room_id =
262					RoomId::parse(row.room_id).invalid_data("Invalid room ID in database")?;
263
264				let backup_data: KeyBackupData = serde_json::from_value(row.backup_data)
265					.invalid_data("Invalid room keys backup data in database")?;
266
267				let room_keys = backup.rooms.entry(room_id).or_default();
268				room_keys.insert(row.session_id, backup_data);
269			}
270		}
271
272		Ok(backup_and_nid.map(|(_nid, backup)| backup))
273	}
274
275	/// Upserts keys in an existing backup.
276	#[tracing::instrument(skip(self))]
277	async fn upsert_backup_data(
278		&mut self,
279		user_id: &UserId,
280		version: i64,
281		new_backup_data: BTreeMap<OwnedRoomId, BTreeMap<String, serde_json::Value>>,
282	) -> Result<(i64, u64), DatabaseError> {
283		for (room_id, room_backups) in new_backup_data {
284			for (session_id, backup_data) in room_backups {
285				sqlx::query!(
286					r#"
287					WITH the_backup AS (
288						SELECT
289							room_keys_versions.nid,
290							etag
291						FROM
292							room_keys_versions
293							JOIN users ON room_keys_versions.user_nid = users.nid
294						WHERE
295							users.user_id = $1
296							AND room_keys_versions.version = $2)
297					INSERT INTO room_keys (room_keys_version_nid, room_nid, session_id, backup_data)
298					VALUES
299						((
300								SELECT
301									nid
302								FROM
303									the_backup),
304								(
305									SELECT
306										nid
307									FROM
308										rooms
309									WHERE
310										room_id = $3), $4, $5)
311						ON CONFLICT (room_keys_version_nid,
312							room_nid,
313							session_id)
314						DO UPDATE SET
315							backup_data = EXCLUDED.backup_data
316					"#,
317					user_id.to_string(),
318					version,
319					room_id.to_string(),
320					session_id,
321					backup_data
322				)
323				.execute(self.connection().await?)
324				.await?;
325			}
326		}
327
328		// Increment and get the `etag` of the backup, and the count of keys
329		// associated to it
330		let etag_and_keys_count = sqlx::query!(
331			r#"
332			WITH updated_backup AS (
333				UPDATE
334					room_keys_versions
335				SET
336					etag = etag + 1
337				FROM
338					users
339				WHERE
340					room_keys_versions.user_nid = users.nid
341					AND users.user_id = $1
342					AND room_keys_versions.version = $2
343				RETURNING
344					room_keys_versions.nid,
345					etag
346			)
347			SELECT
348				etag,
349				COUNT(room_keys) AS "keys_count!"
350			FROM
351				updated_backup
352				LEFT JOIN room_keys ON updated_backup.nid = room_keys.room_keys_version_nid
353			GROUP BY
354				etag
355			"#,
356			user_id.to_string(),
357			version
358		)
359		.fetch_one(self.connection().await?)
360		.await?;
361
362		Ok((etag_and_keys_count.etag, etag_and_keys_count.keys_count as u64))
363	}
364}
365
366impl<'c> TransactedRoomKeysQueries for TransactedDb<'c> {}