| 1 | use std::sync::Arc; |
| 2 | #[cfg(feature = "sqlite")] |
| 3 | use std::{collections::BTreeSet, path::PathBuf}; |
| 4 | |
| 5 | use ruma::TransactionId; |
| 6 | |
| 7 | use crate::{ |
| 8 | AccountError, EncryptedSessionStore, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore, |
| 9 | Session, SessionRecord, SessionStore, SoftLogout, StoreConfig, account::homeserver_input, |
| 10 | engine::StoreLock, |
| 11 | }; |
| 12 | |
| 13 | /// every account one owner has, a store each under one root and one passphrase. whoever holds |
| 14 | /// the set can resume any soft-logged-out device in it with just its password, and the device's |
| 15 | /// store has its e2ee keys, so a set belongs to one person and never to a whole machine |
| 16 | pub struct Accounts { |
| 17 | root: Root, |
| 18 | passphrase: String, |
| 19 | } |
| 20 | |
| 21 | enum Root { |
| 22 | #[cfg(feature = "sqlite")] |
| 23 | Directory(PathBuf), |
| 24 | #[cfg(feature = "indexeddb")] |
| 25 | Prefix(String), |
| 26 | } |
| 27 | |
| 28 | /// one account's record and store |
| 29 | #[derive(Clone)] |
| 30 | pub struct Account { |
| 31 | id: String, |
| 32 | sessions: Arc<EncryptedSessionStore>, |
| 33 | } |
| 34 | |
| 35 | impl Account { |
| 36 | /// stable for the account's life, and safe in a cookie or a url |
| 37 | #[must_use] |
| 38 | pub fn id(&self) -> &str { |
| 39 | &self.id |
| 40 | } |
| 41 | |
| 42 | /// for `Termination::retain`. a discarded account drops out of `Accounts::list` |
| 43 | #[must_use] |
| 44 | pub fn sessions(&self) -> Arc<dyn SessionStore> { |
| 45 | self.sessions.clone() |
| 46 | } |
| 47 | |
| 48 | pub async fn restore(&self) -> Result<Option<Restore>, AccountError> { |
| 49 | Session::restore(self.sessions()).await |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | impl std::fmt::Debug for Account { |
| 54 | fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 55 | formatter |
| 56 | .debug_struct("Account") |
| 57 | .field("id", &self.id) |
| 58 | .finish_non_exhaustive() |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | enum Found { |
| 63 | Nobody, |
| 64 | SoftLoggedOut(Account, SoftLogout), |
| 65 | } |
| 66 | |
| 67 | impl Accounts { |
| 68 | /// each account is a store directory under `root` with its record beside it |
| 69 | #[cfg(feature = "sqlite")] |
| 70 | #[must_use] |
| 71 | pub fn sqlite(root: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self { |
| 72 | Self { |
| 73 | root: Root::Directory(root.into()), |
| 74 | passphrase: passphrase.into(), |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// each account is a store named `<prefix>::<id>`, listed in `<prefix>::macltok-accounts` |
| 79 | #[cfg(feature = "indexeddb")] |
| 80 | #[must_use] |
| 81 | pub fn indexeddb(prefix: impl Into<String>, passphrase: impl Into<String>) -> Self { |
| 82 | Self { |
| 83 | root: Root::Prefix(prefix.into()), |
| 84 | passphrase: passphrase.into(), |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | /// `None` for anything `Account::id` couldn't have been |
| 89 | #[must_use] |
| 90 | pub fn account(&self, id: &str) -> Option<Account> { |
| 91 | let valid = |
| 92 | !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric()); |
| 93 | valid.then(|| self.open(id.to_owned())) |
| 94 | } |
| 95 | |
| 96 | /// every account with a record. a store left without one, by a login that died halfway or a |
| 97 | /// discard that couldn't finish, is deleted on the way past |
| 98 | pub async fn list(&self) -> Result<Vec<Account>, AccountError> { |
| 99 | let mut accounts = Vec::new(); |
| 100 | for account in self.slots().await? { |
| 101 | match account.sessions.load().await { |
| 102 | Ok(None) => self.sweep(&account).await, |
| 103 | Ok(Some(_)) | Err(_) => accounts.push(account), |
| 104 | } |
| 105 | } |
| 106 | Ok(accounts) |
| 107 | } |
| 108 | |
| 109 | /// a soft-logged-out account the login names is resumed as its own device, keeping its keys. |
| 110 | /// anyone else gets a new account |
| 111 | pub async fn login_password( |
| 112 | &self, |
| 113 | login: PasswordLogin, |
| 114 | ) -> Result<(Account, Session), AccountError> { |
| 115 | homeserver_input(&login.homeserver, login.allow_insecure_loopback)?; |
| 116 | let found = self |
| 117 | .find(|record| record_names(record, &login.homeserver, &login.username)) |
| 118 | .await?; |
| 119 | match found { |
| 120 | Found::SoftLoggedOut(account, soft) => { |
| 121 | let store_lock = account.sessions.store().lock().await?; |
| 122 | let login = PasswordLogin { |
| 123 | device_display_name: login.device_display_name, |
| 124 | ..soft.password_login(login.password) |
| 125 | }; |
| 126 | let session = |
| 127 | Session::login_password_locked(login, account.sessions(), store_lock).await?; |
| 128 | Ok((account, session)) |
| 129 | } |
| 130 | Found::Nobody => { |
| 131 | let (account, store_lock) = self.fresh().await?; |
| 132 | let login = PasswordLogin { |
| 133 | device_id: None, |
| 134 | ..login |
| 135 | }; |
| 136 | match Session::login_password_locked(login, account.sessions(), store_lock).await { |
| 137 | Ok(session) => Ok((account, session)), |
| 138 | Err(error) => Err(self.abandon(&account, error).await), |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | /// resumes the soft-logged-out account whose device `login` names, as |
| 145 | /// `SoftLogout::oauth_login` sets it. without one it's a new account, since which account an |
| 146 | /// OAuth login is for isn't known until it finishes |
| 147 | pub async fn begin_oauth( |
| 148 | &self, |
| 149 | login: OAuthLogin, |
| 150 | ) -> Result<(Account, PendingOAuthLogin), AccountError> { |
| 151 | homeserver_input(&login.homeserver, login.allow_insecure_loopback)?; |
| 152 | let device_id = login.device_id.clone(); |
| 153 | let found = self |
| 154 | .find(|record| { |
| 155 | record |
| 156 | .soft_logout() |
| 157 | .is_some_and(|soft| device_id.as_ref() == Some(&soft.device_id)) |
| 158 | }) |
| 159 | .await?; |
| 160 | match found { |
| 161 | Found::SoftLoggedOut(account, soft) => { |
| 162 | let store_lock = account.sessions.store().lock().await?; |
| 163 | let login = OAuthLogin { |
| 164 | homeserver: soft.homeserver, |
| 165 | client_id: login.client_id.or(soft.client_id), |
| 166 | allow_insecure_loopback: true, |
| 167 | ..login |
| 168 | }; |
| 169 | let pending = |
| 170 | Session::begin_oauth_locked(login, account.sessions(), store_lock).await?; |
| 171 | Ok((account, pending)) |
| 172 | } |
| 173 | Found::Nobody => { |
| 174 | let (account, store_lock) = self.fresh().await?; |
| 175 | let login = OAuthLogin { |
| 176 | device_id: None, |
| 177 | ..login |
| 178 | }; |
| 179 | match Session::begin_oauth_locked(login, account.sessions(), store_lock).await { |
| 180 | Ok(pending) => Ok((account, pending)), |
| 181 | Err(error) => Err(self.abandon(&account, error).await), |
| 182 | } |
| 183 | } |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | /// a live login for the same person is refused rather than given a second device |
| 188 | async fn find(&self, resumes: impl Fn(&SessionRecord) -> bool) -> Result<Found, AccountError> { |
| 189 | for account in self.slots().await? { |
| 190 | let Ok(Some(record)) = account.sessions.load().await else { |
| 191 | continue; |
| 192 | }; |
| 193 | if !resumes(&record) { |
| 194 | continue; |
| 195 | } |
| 196 | return match record.soft_logout() { |
| 197 | Some(soft) => Ok(Found::SoftLoggedOut(account, soft)), |
| 198 | None => Err(AccountError::AlreadyLoggedIn(record.user_id().clone())), |
| 199 | }; |
| 200 | } |
| 201 | Ok(Found::Nobody) |
| 202 | } |
| 203 | |
| 204 | /// locked before anything else can see it, so `list` never sweeps a login still under way |
| 205 | async fn fresh(&self) -> Result<(Account, StoreLock), AccountError> { |
| 206 | let account = self.open(TransactionId::new().to_string()); |
| 207 | let store_lock = account.sessions.store().lock().await?; |
| 208 | #[cfg(feature = "indexeddb")] |
| 209 | { |
| 210 | let Root::Prefix(prefix) = &self.root; |
| 211 | index::add(prefix, &account.id).await?; |
| 212 | } |
| 213 | Ok((account, store_lock)) |
| 214 | } |
| 215 | |
| 216 | /// a failed first login leaves nothing behind. one whose record didn't save has a live device |
| 217 | /// on the server, so that stays for `UnsavedSession::save` |
| 218 | async fn abandon(&self, account: &Account, error: AccountError) -> AccountError { |
| 219 | if !matches!(error, AccountError::Unsaved { .. }) { |
| 220 | self.sweep(account).await; |
| 221 | } |
| 222 | error |
| 223 | } |
| 224 | |
| 225 | /// best effort, since whatever it leaves is found and swept again by the next `list` |
| 226 | async fn sweep(&self, account: &Account) { |
| 227 | let Ok(_store_lock) = account.sessions.store().lock().await else { |
| 228 | return; |
| 229 | }; |
| 230 | if !matches!(account.sessions.load().await, Ok(None)) { |
| 231 | return; |
| 232 | } |
| 233 | if account.sessions.discard().await.is_err() { |
| 234 | return; |
| 235 | } |
| 236 | #[cfg(feature = "indexeddb")] |
| 237 | { |
| 238 | let Root::Prefix(prefix) = &self.root; |
| 239 | let _ = index::remove(prefix, &account.id).await; |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | fn open(&self, id: String) -> Account { |
| 244 | let sessions = match &self.root { |
| 245 | #[cfg(feature = "sqlite")] |
| 246 | Root::Directory(root) => EncryptedSessionStore::file( |
| 247 | root.join(format!("{id}.session")), |
| 248 | StoreConfig::sqlite(root.join(&id), self.passphrase.clone()), |
| 249 | ), |
| 250 | #[cfg(feature = "indexeddb")] |
| 251 | Root::Prefix(prefix) => EncryptedSessionStore::indexeddb(StoreConfig::indexeddb( |
| 252 | format!("{prefix}::{id}"), |
| 253 | self.passphrase.clone(), |
| 254 | )), |
| 255 | }; |
| 256 | Account { |
| 257 | id, |
| 258 | sessions: Arc::new(sessions), |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | async fn slots(&self) -> Result<Vec<Account>, AccountError> { |
| 263 | let ids = match &self.root { |
| 264 | #[cfg(feature = "sqlite")] |
| 265 | Root::Directory(root) => directory_slots(root)?, |
| 266 | #[cfg(feature = "indexeddb")] |
| 267 | Root::Prefix(prefix) => index::list(prefix).await?, |
| 268 | }; |
| 269 | Ok(ids.iter().filter_map(|id| self.account(id)).collect()) |
| 270 | } |
| 271 | } |
| 272 | |
| 273 | /// a localpart or a full user id names the same account, but only on the same server. the server |
| 274 | /// is compared by name as well as by url, so a delegated `example.org` still finds the account |
| 275 | /// discovery took to `matrix.example.org` |
| 276 | fn record_names(record: &SessionRecord, homeserver: &str, username: &str) -> bool { |
| 277 | let user_id = record.user_id(); |
| 278 | let username = username.trim(); |
| 279 | let user = if username.starts_with('@') { |
| 280 | username.eq_ignore_ascii_case(user_id.as_str()) |
| 281 | } else { |
| 282 | username.eq_ignore_ascii_case(user_id.localpart()) |
| 283 | }; |
| 284 | let typed = authority(homeserver); |
| 285 | user && (typed.eq_ignore_ascii_case(user_id.server_name().as_str()) |
| 286 | || typed.eq_ignore_ascii_case(authority(record.homeserver()))) |
| 287 | } |
| 288 | |
| 289 | fn authority(homeserver: &str) -> &str { |
| 290 | let homeserver = homeserver.trim(); |
| 291 | let rest = homeserver |
| 292 | .split_once("://") |
| 293 | .map_or(homeserver, |(_, rest)| rest); |
| 294 | rest.split('/').next().unwrap_or(rest) |
| 295 | } |
| 296 | |
| 297 | #[cfg(feature = "sqlite")] |
| 298 | fn directory_slots(root: &std::path::Path) -> Result<BTreeSet<String>, AccountError> { |
| 299 | use std::io::ErrorKind; |
| 300 | |
| 301 | let entries = match std::fs::read_dir(root) { |
| 302 | Ok(entries) => entries, |
| 303 | Err(error) if error.kind() == ErrorKind::NotFound => return Ok(BTreeSet::new()), |
| 304 | Err(error) => return Err(crate::account::account_error("listing the accounts", error)), |
| 305 | }; |
| 306 | let mut ids = BTreeSet::new(); |
| 307 | for entry in entries { |
| 308 | let entry = |
| 309 | entry.map_err(|error| crate::account::account_error("listing the accounts", error))?; |
| 310 | let name = entry.file_name(); |
| 311 | let Some(name) = name.to_str() else { |
| 312 | continue; |
| 313 | }; |
| 314 | if entry.path().is_dir() { |
| 315 | ids.insert(name.to_owned()); |
| 316 | } else if let Some(id) = name.strip_suffix(".session") { |
| 317 | ids.insert(id.to_owned()); |
| 318 | } |
| 319 | } |
| 320 | Ok(ids) |
| 321 | } |
| 322 | |
| 323 | /// a key per account, so two tabs adding at once can't lose one to the other |
| 324 | #[cfg(feature = "indexeddb")] |
| 325 | mod index { |
| 326 | use std::collections::BTreeSet; |
| 327 | |
| 328 | use indexed_db_futures::{ |
| 329 | Build, |
| 330 | database::Database, |
| 331 | error::{Error, OpenDbError}, |
| 332 | prelude::*, |
| 333 | transaction::{Transaction, TransactionMode}, |
| 334 | }; |
| 335 | |
| 336 | use crate::{AccountError, account::account_error}; |
| 337 | |
| 338 | const ACCOUNTS: &str = "accounts"; |
| 339 | |
| 340 | fn name(prefix: &str) -> String { |
| 341 | format!("{prefix}::macltok-accounts") |
| 342 | } |
| 343 | |
| 344 | async fn open(prefix: &str) -> Result<Database, OpenDbError> { |
| 345 | Database::open(&name(prefix)) |
| 346 | .with_version(1u32) |
| 347 | .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> { |
| 348 | transaction.db().create_object_store(ACCOUNTS).build()?; |
| 349 | Ok(()) |
| 350 | }) |
| 351 | .build()? |
| 352 | .await |
| 353 | } |
| 354 | |
| 355 | pub(super) async fn list(prefix: &str) -> Result<BTreeSet<String>, AccountError> { |
| 356 | let failed = |error| account_error("listing the accounts", error); |
| 357 | let database = open(prefix).await.map_err(failed)?; |
| 358 | let ids = keys(&database).await; |
| 359 | database.close(); |
| 360 | ids.map_err(|error| account_error("listing the accounts", error)) |
| 361 | } |
| 362 | |
| 363 | pub(super) async fn add(prefix: &str, id: &str) -> Result<(), AccountError> { |
| 364 | let failed = |error| account_error("adding the account", error); |
| 365 | let database = open(prefix).await.map_err(failed)?; |
| 366 | let added = write(&database, |store| { |
| 367 | store.put(true).with_key(id).build()?; |
| 368 | Ok(()) |
| 369 | }) |
| 370 | .await; |
| 371 | database.close(); |
| 372 | added.map_err(|error| account_error("adding the account", error)) |
| 373 | } |
| 374 | |
| 375 | pub(super) async fn remove(prefix: &str, id: &str) -> Result<(), AccountError> { |
| 376 | let failed = |error| account_error("removing the account", error); |
| 377 | let database = open(prefix).await.map_err(failed)?; |
| 378 | let removed = write(&database, |store| { |
| 379 | store.delete(id).build()?; |
| 380 | Ok(()) |
| 381 | }) |
| 382 | .await; |
| 383 | database.close(); |
| 384 | removed.map_err(|error| account_error("removing the account", error)) |
| 385 | } |
| 386 | |
| 387 | async fn keys(database: &Database) -> Result<BTreeSet<String>, Error> { |
| 388 | let transaction = database |
| 389 | .transaction(ACCOUNTS) |
| 390 | .with_mode(TransactionMode::Readonly) |
| 391 | .build()?; |
| 392 | let store = transaction.object_store(ACCOUNTS)?; |
| 393 | store.get_all_keys::<String>().await?.collect() |
| 394 | } |
| 395 | |
| 396 | async fn write( |
| 397 | database: &Database, |
| 398 | change: impl FnOnce(&indexed_db_futures::object_store::ObjectStore<'_>) -> Result<(), Error>, |
| 399 | ) -> Result<(), Error> { |
| 400 | let transaction = database |
| 401 | .transaction(ACCOUNTS) |
| 402 | .with_mode(TransactionMode::Readwrite) |
| 403 | .build()?; |
| 404 | change(&transaction.object_store(ACCOUNTS)?)?; |
| 405 | transaction.commit().await |
| 406 | } |
| 407 | } |