atagen

macltok

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