atagen

macltok

public
Star0
Watch0
f12377bce9b9427ca9b557bbf34679a4bd6fae14
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
src/session_store.rsRust411 lines13.3 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
use tokio::sync::Mutex;
7
8
use crate::{AccountError, SessionRecord, StoreConfig, account::account_error};
9
10
#[cfg(feature = "sqlite")]
11
type RecordLocation = PathBuf;
12
#[cfg(feature = "indexeddb")]
13
type RecordLocation = String;
14
15
/// one login's record and the matrix store it belongs to. the engine rotates refresh tokens
16
/// and each works once, so a `save` has to land before the next refresh or the login is gone
17
pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {
18
    fn store(&self) -> &StoreConfig;
19
20
    fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>>;
21
22
    /// replace the saved record, all or nothing
23
    fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>>;
24
25
    /// `save`, but without awaiting. the sdk calls it from inside a refresh, before the new
26
    /// refresh token is ever used, so a store that can write synchronously closes the gap
27
    /// a crash would otherwise lose the login through. `None` leaves the refresh to `save`
28
    #[cfg(not(target_family = "wasm"))]
29
    fn save_blocking(&self, _record: &SessionRecord) -> Option<Result<(), AccountError>> {
30
        None
31
    }
32
33
    /// swap the record for one that only says who to log in again as, keeping the store.
34
    /// `Session::restore` then answers `Restore::SoftLoggedOut`, even after a restart
35
    fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>>;
36
37
    /// drop the record and delete the store, once nothing has the store open
38
    fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>>;
39
}
40
41
/// keeps the record sealed with the store's passphrase, in a file natively and in an
42
/// IndexedDB database of its own in a browser
43
pub struct EncryptedSessionStore {
44
    record: RecordLocation,
45
    store: StoreConfig,
46
    sealer: Mutex<Option<Sealer>>,
47
}
48
49
struct Sealer {
50
    cipher: StoreCipher,
51
    exported: Vec<u8>,
52
}
53
54
impl EncryptedSessionStore {
55
    #[cfg(feature = "sqlite")]
56
    #[must_use]
57
    pub fn file(record: impl Into<PathBuf>, store: StoreConfig) -> Self {
58
        Self::new(record.into(), store)
59
    }
60
61
    /// the record lives beside the store, in `<name>::macltok-session`
62
    #[cfg(feature = "indexeddb")]
63
    #[must_use]
64
    pub fn indexeddb(store: StoreConfig) -> Self {
65
        let record = format!("{}::macltok-session", store.indexeddb_name());
66
        Self::new(record, store)
67
    }
68
69
    fn new(record: RecordLocation, store: StoreConfig) -> Self {
70
        Self {
71
            record,
72
            store,
73
            sealer: Mutex::new(None),
74
        }
75
    }
76
77
    async fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {
78
        let mut sealer = self.sealer.lock().await;
79
        self.read(&mut sealer).await
80
    }
81
82
    async fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {
83
        let mut sealer = self.sealer.lock().await;
84
        self.write(&mut sealer, record).await
85
    }
86
87
    async fn soft_log_out_record(&self) -> Result<(), AccountError> {
88
        let mut sealer = self.sealer.lock().await;
89
        match self.read(&mut sealer).await? {
90
            Some(record) => self.write(&mut sealer, &record.soft_logged_out()).await,
91
            None => Ok(()),
92
        }
93
    }
94
95
    async fn discard_all(&self) -> Result<(), AccountError> {
96
        let mut sealer = self.sealer.lock().await;
97
        backend::remove(&self.record)
98
            .await
99
            .map_err(|error| account_error("removing the session record", error))?;
100
        *sealer = None;
101
        self.store.delete().await
102
    }
103
104
    async fn read(
105
        &self,
106
        sealer: &mut Option<Sealer>,
107
    ) -> Result<Option<SessionRecord>, AccountError> {
108
        let Some(sealed) = backend::read(&self.record)
109
            .await
110
            .map_err(|error| account_error("reading the session record", error))?
111
        else {
112
            return Ok(None);
113
        };
114
        let (opened, record) = unseal(self.store.passphrase(), &sealed)?;
115
        *sealer = Some(opened);
116
        Ok(Some(record))
117
    }
118
119
    async fn write(
120
        &self,
121
        sealer: &mut Option<Sealer>,
122
        record: &SessionRecord,
123
    ) -> Result<(), AccountError> {
124
        let sealed = self.seal(sealer, record)?;
125
        backend::write(&self.record, &sealed)
126
            .await
127
            .map_err(|error| account_error("writing the session record", error))
128
    }
129
130
    fn seal(
131
        &self,
132
        sealer: &mut Option<Sealer>,
133
        record: &SessionRecord,
134
    ) -> Result<Vec<u8>, AccountError> {
135
        let sealer = match sealer {
136
            Some(sealer) => sealer,
137
            empty => empty.insert(Sealer::new(self.store.passphrase())?),
138
        };
139
        sealer.seal(record)
140
    }
141
142
    /// the lock is only ever held around one small write, so losing the race to it just
143
    /// hands this refresh to the async save
144
    #[cfg(feature = "sqlite")]
145
    fn save_now(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {
146
        let mut sealer = self.sealer.try_lock().ok()?;
147
        Some(self.seal(&mut sealer, record).and_then(|sealed| {
148
            backend::write_blocking(&self.record, &sealed)
149
                .map_err(|error| account_error("writing the session record", error))
150
        }))
151
    }
152
}
153
154
impl SessionStore for EncryptedSessionStore {
155
    fn store(&self) -> &StoreConfig {
156
        &self.store
157
    }
158
159
    fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {
160
        Box::pin(self.load_record())
161
    }
162
163
    fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {
164
        Box::pin(self.save_record(record))
165
    }
166
167
    #[cfg(feature = "sqlite")]
168
    fn save_blocking(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {
169
        self.save_now(record)
170
    }
171
172
    fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {
173
        Box::pin(self.soft_log_out_record())
174
    }
175
176
    fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {
177
        Box::pin(self.discard_all())
178
    }
179
}
180
181
impl Sealer {
182
    fn new(passphrase: &str) -> Result<Self, AccountError> {
183
        let cipher = StoreCipher::new()
184
            .map_err(|error| account_error("creating the session record cipher", error))?;
185
        let exported = cipher
186
            .export(passphrase)
187
            .map_err(|error| account_error("sealing the session record cipher", error))?;
188
        Ok(Self { cipher, exported })
189
    }
190
191
    fn seal(&self, record: &SessionRecord) -> Result<Vec<u8>, AccountError> {
192
        let value = self
193
            .cipher
194
            .encrypt_value(record)
195
            .map_err(|error| account_error("sealing the session record", error))?;
196
        let length = u32::try_from(self.exported.len())
197
            .expect("an exported store cipher is a few hundred bytes");
198
        let mut sealed = Vec::with_capacity(4 + self.exported.len() + value.len());
199
        sealed.extend_from_slice(&length.to_be_bytes());
200
        sealed.extend_from_slice(&self.exported);
201
        sealed.extend_from_slice(&value);
202
        Ok(sealed)
203
    }
204
}
205
206
fn unseal(passphrase: &str, sealed: &[u8]) -> Result<(Sealer, SessionRecord), AccountError> {
207
    let truncated = || {
208
        account_error(
209
            "opening the session record",
210
            std::io::Error::from(std::io::ErrorKind::UnexpectedEof),
211
        )
212
    };
213
    let (length, rest) = sealed.split_first_chunk::<4>().ok_or_else(truncated)?;
214
    let (exported, value) = rest
215
        .split_at_checked(u32::from_be_bytes(*length) as usize)
216
        .ok_or_else(truncated)?;
217
    let cipher = StoreCipher::import(passphrase, exported)
218
        .map_err(|error| account_error("opening the session record cipher", error))?;
219
    let record = cipher
220
        .decrypt_value(value)
221
        .map_err(|error| account_error("opening the session record", error))?;
222
    let sealer = Sealer {
223
        cipher,
224
        exported: exported.to_vec(),
225
    };
226
    Ok((sealer, record))
227
}
228
229
#[cfg(feature = "sqlite")]
230
mod backend {
231
    use std::{
232
        fs::{self, File, OpenOptions},
233
        io::{self, ErrorKind, Write},
234
        path::{Path, PathBuf},
235
    };
236
237
    pub(super) async fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {
238
        match fs::read(path) {
239
            Ok(sealed) => Ok(Some(sealed)),
240
            Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
241
            Err(error) => Err(error),
242
        }
243
    }
244
245
    pub(super) async fn write(path: &Path, sealed: &[u8]) -> io::Result<()> {
246
        write_blocking(path, sealed)
247
    }
248
249
    pub(super) fn write_blocking(path: &Path, sealed: &[u8]) -> io::Result<()> {
250
        let mut temporary = path.as_os_str().to_owned();
251
        temporary.push(".tmp");
252
        let temporary = PathBuf::from(temporary);
253
        let parent = match path.parent() {
254
            Some(parent) if !parent.as_os_str().is_empty() => parent,
255
            _ => Path::new("."),
256
        };
257
        fs::create_dir_all(parent)?;
258
        let written = replace(path, &temporary, parent, sealed);
259
        if written.is_err() {
260
            let _ = fs::remove_file(&temporary);
261
        }
262
        written
263
    }
264
265
    fn replace(path: &Path, temporary: &Path, parent: &Path, sealed: &[u8]) -> io::Result<()> {
266
        let mut file = private_file(temporary)?;
267
        file.write_all(sealed)?;
268
        file.sync_all()?;
269
        fs::rename(temporary, path)?;
270
        sync_directory(parent)
271
    }
272
273
    pub(super) async fn remove(path: &Path) -> io::Result<()> {
274
        match fs::remove_file(path) {
275
            Err(error) if error.kind() != ErrorKind::NotFound => Err(error),
276
            _ => Ok(()),
277
        }
278
    }
279
280
    #[cfg(unix)]
281
    fn private_file(path: &Path) -> io::Result<File> {
282
        use std::os::unix::fs::OpenOptionsExt;
283
        OpenOptions::new()
284
            .write(true)
285
            .create(true)
286
            .truncate(true)
287
            .mode(0o600)
288
            .open(path)
289
    }
290
291
    #[cfg(not(unix))]
292
    fn private_file(path: &Path) -> io::Result<File> {
293
        OpenOptions::new()
294
            .write(true)
295
            .create(true)
296
            .truncate(true)
297
            .open(path)
298
    }
299
300
    /// a rename isn't durable until its directory is synced
301
    #[cfg(unix)]
302
    fn sync_directory(path: &Path) -> io::Result<()> {
303
        File::open(path)?.sync_all()
304
    }
305
306
    #[cfg(not(unix))]
307
    fn sync_directory(_: &Path) -> io::Result<()> {
308
        Ok(())
309
    }
310
}
311
312
#[cfg(feature = "indexeddb")]
313
mod backend {
314
    use indexed_db_futures::{
315
        Build,
316
        database::Database,
317
        error::{Error, OpenDbError},
318
        prelude::*,
319
        transaction::{Transaction, TransactionMode},
320
        typed_array::{Uint8Array, Uint8ArraySlice},
321
    };
322
323
    const RECORDS: &str = "records";
324
    const KEY: &str = "record";
325
326
    async fn open(name: &str) -> Result<Database, OpenDbError> {
327
        Database::open(name)
328
            .with_version(1u32)
329
            .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> {
330
                transaction.db().create_object_store(RECORDS).build()?;
331
                Ok(())
332
            })
333
            .build()?
334
            .await
335
    }
336
337
    pub(super) async fn read(name: &str) -> Result<Option<Vec<u8>>, OpenDbError> {
338
        let database = open(name).await?;
339
        let sealed: Option<Uint8Array> = database
340
            .transaction(RECORDS)
341
            .with_mode(TransactionMode::Readonly)
342
            .build()?
343
            .object_store(RECORDS)?
344
            .get(KEY)
345
            .await?;
346
        Ok(sealed.map(Into::into))
347
    }
348
349
    pub(super) async fn write(name: &str, sealed: &[u8]) -> Result<(), OpenDbError> {
350
        let database = open(name).await?;
351
        let transaction = database
352
            .transaction(RECORDS)
353
            .with_mode(TransactionMode::Readwrite)
354
            .build()?;
355
        transaction
356
            .object_store(RECORDS)?
357
            .put(Uint8ArraySlice::new(sealed))
358
            .with_key(KEY)
359
            .build()?;
360
        Ok(transaction.commit().await?)
361
    }
362
363
    pub(super) async fn remove(name: &str) -> Result<(), OpenDbError> {
364
        Ok(Database::delete_by_name(name)?.await?)
365
    }
366
}
367
368
#[cfg(feature = "sqlite")]
369
pub(crate) fn delete_sqlite_store(path: &Path) -> std::io::Result<()> {
370
    use std::{fs, io::ErrorKind};
371
372
    // matrix-sdk-sqlite 0.18's database names, which it keeps private
373
    const DATABASES: [&str; 4] = [
374
        "matrix-sdk-state.sqlite3",
375
        "matrix-sdk-crypto.sqlite3",
376
        "matrix-sdk-event-cache.sqlite3",
377
        "matrix-sdk-media.sqlite3",
378
    ];
379
    for database in DATABASES {
380
        for suffix in ["", "-wal", "-shm", "-journal"] {
381
            match fs::remove_file(path.join(format!("{database}{suffix}"))) {
382
                Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
383
                _ => {}
384
            }
385
        }
386
    }
387
    match fs::remove_dir(path) {
388
        Err(error) if error.kind() != ErrorKind::NotFound => Err(error),
389
        _ => Ok(()),
390
    }
391
}
392
393
#[cfg(feature = "indexeddb")]
394
pub(crate) async fn delete_indexeddb_store(
395
    name: &str,
396
) -> Result<(), indexed_db_futures::error::OpenDbError> {
397
    use indexed_db_futures::database::Database;
398
399
    // the databases matrix-sdk-indexeddb 0.18 opens under one store name
400
    for suffix in [
401
        "",
402
        "::matrix-sdk-state",
403
        "::matrix-sdk-crypto",
404
        "::matrix-sdk-crypto-meta",
405
        "::event_cache",
406
        "::media",
407
    ] {
408
        Database::delete_by_name(&format!("{name}{suffix}"))?.await?;
409
    }
410
    Ok(())
411
}