atagen

macltok

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