atagen

macltok

public
Star0
Watch0
17e042fbc3316ffae2dc316cadbc20c8ce863a9d
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/accounts.rsRust407 lines14.4 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, 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
}