atagen

macltok

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