atagen

macltok

public
Star0
Watch0
6f47f7cbe75aca541fb684d8bf35a7758c3a87d7
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/session_store.rsRust570 lines18.6 KB
1
#[cfg(feature = "sqlite")]
2
use std::path::{Path, PathBuf};
3
4
use matrix_sdk::{BoxFuture, SendOutsideWasm, SyncOutsideWasm};
5
use matrix_sdk_store_encryption::StoreCipher;
6
7
use crate::{AccountError, SessionRecord, StoreConfig, account::account_error};
8
9
#[cfg(feature = "sqlite")]
10
type RecordLocation = PathBuf;
11
#[cfg(feature = "indexeddb")]
12
type RecordLocation = String;
13
14
#[cfg(not(target_family = "wasm"))]
15
type Lock<T> = std::sync::Mutex<T>;
16
#[cfg(target_family = "wasm")]
17
type Lock<T> = tokio::sync::Mutex<T>;
18
19
/// one login's record and the matrix store it belongs to. the engine rotates refresh tokens
20
/// and each works once, so a `save` has to land before the next refresh or the login is gone
21
pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {
22
    fn store(&self) -> &StoreConfig;
23
24
    fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>>;
25
26
    /// replace the saved record, all or nothing
27
    fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>>;
28
29
    /// `save`, but without awaiting. the sdk calls it from inside a refresh, before the new
30
    /// refresh token is ever used, so a store that can write synchronously closes the gap
31
    /// a crash would otherwise lose the login through. `None` leaves the refresh to `save`
32
    #[cfg(not(target_family = "wasm"))]
33
    fn save_blocking(&self, _record: &SessionRecord) -> Option<Result<(), AccountError>> {
34
        None
35
    }
36
37
    /// swap the record for one that only says who to log in again as, keeping the store.
38
    /// `Session::restore` then answers `Restore::SoftLoggedOut`, even after a restart
39
    fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>>;
40
41
    /// drop the record and delete the store, once nothing has the store open
42
    fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>>;
43
}
44
45
/// keeps the record sealed with the store's passphrase, in a file natively and in an
46
/// IndexedDB database of its own in a browser
47
pub struct EncryptedSessionStore {
48
    record: RecordLocation,
49
    store: StoreConfig,
50
    sealer: Lock<Option<Sealer>>,
51
}
52
53
struct Sealer {
54
    cipher: StoreCipher,
55
    exported: Vec<u8>,
56
}
57
58
impl EncryptedSessionStore {
59
    #[cfg(feature = "sqlite")]
60
    #[must_use]
61
    pub fn file(record: impl Into<PathBuf>, store: StoreConfig) -> Self {
62
        Self::new(record.into(), store)
63
    }
64
65
    /// the record lives beside the store, in `<name>::macltok-session`
66
    #[cfg(feature = "indexeddb")]
67
    #[must_use]
68
    pub fn indexeddb(store: StoreConfig) -> Self {
69
        let record = format!("{}::macltok-session", store.indexeddb_name());
70
        Self::new(record, store)
71
    }
72
73
    fn new(record: RecordLocation, store: StoreConfig) -> Self {
74
        Self {
75
            record,
76
            store,
77
            sealer: Lock::new(None),
78
        }
79
    }
80
81
    fn open(
82
        &self,
83
        sealer: &mut Option<Sealer>,
84
        sealed: Option<Vec<u8>>,
85
    ) -> Result<Option<SessionRecord>, AccountError> {
86
        let Some(sealed) = sealed else {
87
            return Ok(None);
88
        };
89
        let (opened, record) = unseal(self.store.passphrase(), &sealed)?;
90
        *sealer = Some(opened);
91
        Ok(Some(record))
92
    }
93
94
    fn seal(
95
        &self,
96
        sealer: &mut Option<Sealer>,
97
        record: &SessionRecord,
98
    ) -> Result<Vec<u8>, AccountError> {
99
        let sealer = match sealer {
100
            Some(sealer) => sealer,
101
            empty => empty.insert(Sealer::new(self.store.passphrase())?),
102
        };
103
        sealer.seal(record)
104
    }
105
}
106
107
#[cfg(not(target_family = "wasm"))]
108
impl EncryptedSessionStore {
109
    fn lock(&self) -> std::sync::MutexGuard<'_, Option<Sealer>> {
110
        self.sealer
111
            .lock()
112
            .unwrap_or_else(std::sync::PoisonError::into_inner)
113
    }
114
115
    fn read(&self, sealer: &mut Option<Sealer>) -> Result<Option<SessionRecord>, AccountError> {
116
        let sealed = backend::read(&self.record)
117
            .map_err(|error| account_error("reading the session record", error))?;
118
        self.open(sealer, sealed)
119
    }
120
121
    fn write(
122
        &self,
123
        sealer: &mut Option<Sealer>,
124
        record: &SessionRecord,
125
    ) -> Result<(), AccountError> {
126
        let sealed = self.seal(sealer, record)?;
127
        backend::write(&self.record, &sealed)
128
            .map_err(|error| account_error("writing the session record", error))
129
    }
130
131
    fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {
132
        self.read(&mut self.lock())
133
    }
134
135
    fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {
136
        self.write(&mut self.lock(), record)
137
    }
138
139
    fn soft_log_out_record(&self) -> Result<(), AccountError> {
140
        let mut sealer = self.lock();
141
        match self.read(&mut sealer)? {
142
            Some(record) => self.write(&mut sealer, &record.soft_logged_out()),
143
            None => Ok(()),
144
        }
145
    }
146
147
    fn forget_record(&self) -> Result<(), AccountError> {
148
        let mut sealer = self.lock();
149
        backend::remove(&self.record)
150
            .map_err(|error| account_error("removing the session record", error))?;
151
        *sealer = None;
152
        Ok(())
153
    }
154
}
155
156
#[cfg(not(target_family = "wasm"))]
157
impl SessionStore for EncryptedSessionStore {
158
    fn store(&self) -> &StoreConfig {
159
        &self.store
160
    }
161
162
    fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {
163
        Box::pin(async move { self.load_record() })
164
    }
165
166
    fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {
167
        Box::pin(async move { self.save_record(record) })
168
    }
169
170
    /// blocks the calling worker for the write and its fsyncs, about a millisecond
171
    fn save_blocking(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {
172
        Some(self.save_record(record))
173
    }
174
175
    fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {
176
        Box::pin(async move { self.soft_log_out_record() })
177
    }
178
179
    fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {
180
        Box::pin(async move {
181
            self.forget_record()?;
182
            self.store.delete().await
183
        })
184
    }
185
}
186
187
#[cfg(target_family = "wasm")]
188
impl EncryptedSessionStore {
189
    async fn read(
190
        &self,
191
        sealer: &mut Option<Sealer>,
192
    ) -> Result<Option<SessionRecord>, AccountError> {
193
        let sealed = backend::read(&self.record)
194
            .await
195
            .map_err(|error| account_error("reading the session record", error))?;
196
        self.open(sealer, sealed)
197
    }
198
199
    async fn write(
200
        &self,
201
        sealer: &mut Option<Sealer>,
202
        record: &SessionRecord,
203
    ) -> Result<(), AccountError> {
204
        let sealed = self.seal(sealer, record)?;
205
        backend::write(&self.record, &sealed)
206
            .await
207
            .map_err(|error| account_error("writing the session record", error))
208
    }
209
210
    async fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {
211
        self.read(&mut *self.sealer.lock().await).await
212
    }
213
214
    async fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {
215
        self.write(&mut *self.sealer.lock().await, record).await
216
    }
217
218
    async fn soft_log_out_record(&self) -> Result<(), AccountError> {
219
        let mut sealer = self.sealer.lock().await;
220
        match self.read(&mut sealer).await? {
221
            Some(record) => self.write(&mut sealer, &record.soft_logged_out()).await,
222
            None => Ok(()),
223
        }
224
    }
225
226
    async fn discard_all(&self) -> Result<(), AccountError> {
227
        let mut sealer = self.sealer.lock().await;
228
        backend::remove(&self.record)
229
            .await
230
            .map_err(|error| error.into_account_error("removing the session record"))?;
231
        *sealer = None;
232
        self.store.delete().await
233
    }
234
}
235
236
#[cfg(target_family = "wasm")]
237
impl SessionStore for EncryptedSessionStore {
238
    fn store(&self) -> &StoreConfig {
239
        &self.store
240
    }
241
242
    fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {
243
        Box::pin(self.load_record())
244
    }
245
246
    fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {
247
        Box::pin(self.save_record(record))
248
    }
249
250
    fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {
251
        Box::pin(self.soft_log_out_record())
252
    }
253
254
    fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {
255
        Box::pin(self.discard_all())
256
    }
257
}
258
259
impl Sealer {
260
    fn new(passphrase: &str) -> Result<Self, AccountError> {
261
        let cipher = StoreCipher::new()
262
            .map_err(|error| account_error("creating the session record cipher", error))?;
263
        let exported = cipher
264
            .export(passphrase)
265
            .map_err(|error| account_error("sealing the session record cipher", error))?;
266
        Ok(Self { cipher, exported })
267
    }
268
269
    fn seal(&self, record: &SessionRecord) -> Result<Vec<u8>, AccountError> {
270
        let value = self
271
            .cipher
272
            .encrypt_value(record)
273
            .map_err(|error| account_error("sealing the session record", error))?;
274
        let length = u32::try_from(self.exported.len())
275
            .expect("an exported store cipher is a few hundred bytes");
276
        let mut sealed = Vec::with_capacity(4 + self.exported.len() + value.len());
277
        sealed.extend_from_slice(&length.to_be_bytes());
278
        sealed.extend_from_slice(&self.exported);
279
        sealed.extend_from_slice(&value);
280
        Ok(sealed)
281
    }
282
}
283
284
fn unseal(passphrase: &str, sealed: &[u8]) -> Result<(Sealer, SessionRecord), AccountError> {
285
    let truncated = || {
286
        account_error(
287
            "opening the session record",
288
            std::io::Error::from(std::io::ErrorKind::UnexpectedEof),
289
        )
290
    };
291
    let (length, rest) = sealed.split_first_chunk::<4>().ok_or_else(truncated)?;
292
    let (exported, value) = rest
293
        .split_at_checked(u32::from_be_bytes(*length) as usize)
294
        .ok_or_else(truncated)?;
295
    let cipher = StoreCipher::import(passphrase, exported)
296
        .map_err(|error| account_error("opening the session record cipher", error))?;
297
    let record = cipher
298
        .decrypt_value(value)
299
        .map_err(|error| account_error("opening the session record", error))?;
300
    let sealer = Sealer {
301
        cipher,
302
        exported: exported.to_vec(),
303
    };
304
    Ok((sealer, record))
305
}
306
307
#[cfg(feature = "sqlite")]
308
mod backend {
309
    use std::{
310
        ffi::OsString,
311
        fs::{self, File, OpenOptions},
312
        io::{self, ErrorKind, Write},
313
        path::{Path, PathBuf},
314
        sync::atomic::{AtomicU64, Ordering},
315
    };
316
317
    static WRITES: AtomicU64 = AtomicU64::new(0);
318
319
    pub(super) fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {
320
        match fs::read(path) {
321
            Ok(sealed) => Ok(Some(sealed)),
322
            Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
323
            Err(error) => Err(error),
324
        }
325
    }
326
327
    pub(super) fn write(path: &Path, sealed: &[u8]) -> io::Result<()> {
328
        let parent = match path.parent() {
329
            Some(parent) if !parent.as_os_str().is_empty() => parent,
330
            _ => Path::new("."),
331
        };
332
        fs::create_dir_all(parent)?;
333
        let (temporary, mut file) = temporary_file(path)?;
334
        let written = replace(path, &temporary, &mut file, parent, sealed);
335
        if written.is_err() {
336
            let _ = fs::remove_file(&temporary);
337
        }
338
        written
339
    }
340
341
    fn temporary_file(path: &Path) -> io::Result<(PathBuf, File)> {
342
        loop {
343
            let mut name = OsString::from(path.as_os_str());
344
            let write = WRITES.fetch_add(1, Ordering::Relaxed);
345
            name.push(format!(".{}.{write}.tmp", std::process::id()));
346
            let temporary = PathBuf::from(name);
347
            match private_file(&temporary) {
348
                Ok(file) => return Ok((temporary, file)),
349
                Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
350
                Err(error) => return Err(error),
351
            }
352
        }
353
    }
354
355
    fn replace(
356
        path: &Path,
357
        temporary: &Path,
358
        file: &mut File,
359
        parent: &Path,
360
        sealed: &[u8],
361
    ) -> io::Result<()> {
362
        file.write_all(sealed)?;
363
        file.sync_all()?;
364
        fs::rename(temporary, path)?;
365
        sync_directory(parent)
366
    }
367
368
    pub(super) fn remove(path: &Path) -> io::Result<()> {
369
        match fs::remove_file(path) {
370
            Err(error) if error.kind() != ErrorKind::NotFound => Err(error),
371
            _ => Ok(()),
372
        }
373
    }
374
375
    /// `create_new` is `O_EXCL`, which posix says never follows a symlink, even a dangling one
376
    #[cfg(unix)]
377
    fn private_file(path: &Path) -> io::Result<File> {
378
        use std::os::unix::fs::OpenOptionsExt;
379
        OpenOptions::new()
380
            .write(true)
381
            .create_new(true)
382
            .mode(0o600)
383
            .open(path)
384
    }
385
386
    #[cfg(not(unix))]
387
    fn private_file(path: &Path) -> io::Result<File> {
388
        OpenOptions::new().write(true).create_new(true).open(path)
389
    }
390
391
    /// a rename isn't durable until its directory is synced
392
    #[cfg(unix)]
393
    fn sync_directory(path: &Path) -> io::Result<()> {
394
        File::open(path)?.sync_all()
395
    }
396
397
    #[cfg(not(unix))]
398
    fn sync_directory(_: &Path) -> io::Result<()> {
399
        Ok(())
400
    }
401
}
402
403
#[cfg(feature = "indexeddb")]
404
mod backend {
405
    use indexed_db_futures::{
406
        Build,
407
        database::Database,
408
        error::{Error, OpenDbError},
409
        prelude::*,
410
        transaction::{Transaction, TransactionMode},
411
        typed_array::{Uint8Array, Uint8ArraySlice},
412
    };
413
414
    const RECORDS: &str = "records";
415
    const KEY: &str = "record";
416
417
    async fn open(name: &str) -> Result<Database, OpenDbError> {
418
        Database::open(name)
419
            .with_version(1u32)
420
            .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> {
421
                transaction.db().create_object_store(RECORDS).build()?;
422
                Ok(())
423
            })
424
            .build()?
425
            .await
426
    }
427
428
    /// the crate's `Database` never closes itself on drop, and an open one blocks deleting it
429
    pub(super) async fn read(name: &str) -> Result<Option<Vec<u8>>, OpenDbError> {
430
        let database = open(name).await?;
431
        let sealed = get(&database).await;
432
        database.close();
433
        Ok(sealed?.map(Into::into))
434
    }
435
436
    pub(super) async fn write(name: &str, sealed: &[u8]) -> Result<(), OpenDbError> {
437
        let database = open(name).await?;
438
        let written = put(&database, sealed).await;
439
        database.close();
440
        Ok(written?)
441
    }
442
443
    pub(super) async fn remove(name: &str) -> Result<(), super::DeleteError> {
444
        super::delete_database(name).await
445
    }
446
447
    async fn get(database: &Database) -> Result<Option<Uint8Array>, Error> {
448
        database
449
            .transaction(RECORDS)
450
            .with_mode(TransactionMode::Readonly)
451
            .build()?
452
            .object_store(RECORDS)?
453
            .get(KEY)
454
            .await
455
    }
456
457
    async fn put(database: &Database, sealed: &[u8]) -> Result<(), Error> {
458
        let transaction = database
459
            .transaction(RECORDS)
460
            .with_mode(TransactionMode::Readwrite)
461
            .build()?;
462
        transaction
463
            .object_store(RECORDS)?
464
            .put(Uint8ArraySlice::new(sealed))
465
            .with_key(KEY)
466
            .build()?;
467
        transaction.commit().await
468
    }
469
}
470
471
#[cfg(feature = "sqlite")]
472
pub(crate) fn delete_sqlite_store(path: &Path) -> std::io::Result<()> {
473
    use std::{fs, io::ErrorKind};
474
475
    // matrix-sdk-sqlite 0.18's database names, which it keeps private
476
    const DATABASES: [&str; 6] = [
477
        "matrix-sdk-state.sqlite3",
478
        "matrix-sdk-crypto.sqlite3",
479
        "matrix-sdk-event-cache.sqlite3",
480
        "matrix-sdk-media.sqlite3",
481
        "macltok-mentions.sqlite3",
482
        "macltok-search.sqlite3",
483
    ];
484
    for database in DATABASES {
485
        for suffix in ["", "-wal", "-shm", "-journal"] {
486
            match fs::remove_file(path.join(format!("{database}{suffix}"))) {
487
                Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
488
                _ => {}
489
            }
490
        }
491
    }
492
    match fs::remove_file(path.join(crate::store_lock::LOCK_FILE)) {
493
        Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
494
        _ => {}
495
    }
496
    match fs::remove_dir(path) {
497
        Err(error) if error.kind() != ErrorKind::NotFound => Err(error),
498
        _ => Ok(()),
499
    }
500
}
501
502
#[cfg(feature = "indexeddb")]
503
pub(crate) async fn delete_indexeddb_store(name: &str) -> Result<(), DeleteError> {
504
    // the databases matrix-sdk-indexeddb 0.18 opens under one store name
505
    for suffix in [
506
        "",
507
        "::matrix-sdk-state",
508
        "::matrix-sdk-crypto",
509
        "::matrix-sdk-crypto-meta",
510
        "::event_cache",
511
        "::media",
512
        "::macltok-mentions",
513
        "::macltok-search",
514
    ] {
515
        delete_database(&format!("{name}{suffix}")).await?;
516
    }
517
    Ok(())
518
}
519
520
#[cfg(feature = "indexeddb")]
521
#[derive(Debug, thiserror::Error)]
522
pub(crate) enum DeleteError {
523
    #[error("the database is still open somewhere, it goes once that closes")]
524
    Blocked,
525
    #[error("this global scope has no IndexedDB")]
526
    Unavailable,
527
    #[error("deleting the database failed, {0}")]
528
    Failed(String),
529
}
530
531
#[cfg(feature = "indexeddb")]
532
impl DeleteError {
533
    pub(crate) fn into_account_error(self, context: &'static str) -> AccountError {
534
        match self {
535
            Self::Blocked => AccountError::StoreDeletePending,
536
            error => account_error(context, error),
537
        }
538
    }
539
}
540
541
/// matrix_indexed_db_futures 0.7 ignores `blocked` on a delete, which then waits forever
542
#[cfg(feature = "indexeddb")]
543
async fn delete_database(name: &str) -> Result<(), DeleteError> {
544
    use wasm_bindgen::{JsCast, JsValue};
545
546
    const BLOCKED: &str = "blocked";
547
    let global = js_sys::global();
548
    let factory = if let Some(window) = global.dyn_ref::<web_sys::Window>() {
549
        window.indexed_db()
550
    } else if let Some(worker) = global.dyn_ref::<web_sys::WorkerGlobalScope>() {
551
        worker.indexed_db()
552
    } else {
553
        return Err(DeleteError::Unavailable);
554
    };
555
    let factory = factory.ok().flatten().ok_or(DeleteError::Unavailable)?;
556
    let request = factory
557
        .delete_database(name)
558
        .map_err(|error| DeleteError::Failed(format!("{error:?}")))?;
559
    let settled = js_sys::Promise::new(&mut |resolve, reject| {
560
        request.set_onsuccess(Some(&resolve));
561
        request.set_onerror(Some(&reject));
562
        let blocked = reject.bind1(&JsValue::NULL, &JsValue::from_str(BLOCKED));
563
        request.set_onblocked(Some(blocked.unchecked_ref()));
564
    });
565
    match wasm_bindgen_futures::JsFuture::from(settled).await {
566
        Ok(_) => Ok(()),
567
        Err(reason) if reason.as_string().as_deref() == Some(BLOCKED) => Err(DeleteError::Blocked),
568
        Err(_) => Err(DeleteError::Failed(format!("{:?}", request.error()))),
569
    }
570
}