Commit 713205a95af2
tarball9 files changed+547-85
@@ -1830,8 +1830,10 @@ dependencies = [
18301830
"imbl",18311831
"matrix-sdk",18321832
"matrix-sdk-crypto",1833
+ "matrix-sdk-store-encryption",18331834
"matrix-sdk-test",18341835
"matrix-sdk-ui",1836
+ "matrix_indexed_db_futures",18351837
"mime",18361838
"reqwest",18371839
"ruma",@@ -7,9 +7,15 @@ description = "A compact application backend on top of the Matrix Rust SDK"
77
[features]88
default = []99
serde = ["dep:serde", "imbl/serde"]10
-sqlite = ["matrix-sdk/sqlite", "serde", "dep:url"]10
+sqlite = ["matrix-sdk/sqlite", "serde", "dep:url", "dep:matrix-sdk-store-encryption"]1111
bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]12
-indexeddb = ["matrix-sdk/indexeddb", "serde", "dep:url"]12
+indexeddb = [13
+ "matrix-sdk/indexeddb",14
+ "serde",15
+ "dep:url",16
+ "dep:matrix-sdk-store-encryption",17
+ "dep:indexed_db_futures",18
+]1319
1420
[dependencies]1521
futures-util = { version = "0.3.31", default-features = false, features = ["std"] }@@ -17,6 +23,7 @@ http = "1"
1723
imbl = { version = "6.1.0", default-features = false }1824
matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }1925
matrix-sdk-crypto = { version = "0.18.0", default-features = false }26
+matrix-sdk-store-encryption = { version = "0.18.0", optional = true }2027
matrix-sdk-ui = { version = "0.18.0", default-features = false }2128
ruma = { version = "0.16.0", default-features = false }2229
serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }@@ -31,6 +38,7 @@ url = { version = "2.5.7", optional = true }
3138
getrandom = { version = "0.2", features = ["js"] }3239
getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }3340
getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }41
+indexed_db_futures = { package = "matrix_indexed_db_futures", version = "0.7.0", optional = true, features = ["typed-arrays"] }3442
matrix-sdk = { version = "0.18.0", default-features = false, features = ["js"] }3543
matrix-sdk-ui = { version = "0.18.0", default-features = false, features = ["js"] }3644
# the sdk's own wasm reqwest leaves out stream, which the bounded media download needs@@ -41,20 +41,22 @@ with `TimelineDiff::try_apply_all`. snapshots are `imbl::Vector`s, re-exported
4141
as `macltok::imbl`, so a copy costs nothing. when a batch doesn't fit none of it4242
lands. stop applying, call `resync`, and pick up from the next `TimelineReset`4343
44
-With `sqlite` or `indexeddb`, persist the initial `Session::record()`45
-separately from its encrypted store. On `SessionTokensChanged`, obtain a new record from46
-`ClientHandle::session_record()` and replace the saved record atomically in47
-protected storage. Restore with the same store location and passphrase.44
+with `sqlite` or `indexeddb`, a `SessionStore` holds one login's record and its45
+store. `EncryptedSessionStore` seals the record with the store's passphrase, in a46
+file you pick natively or an IndexedDB database beside the store in a browser.47
+logging in saves the first record and `Session::restore` loads it. on every48
+`SessionTokensChanged`, `save` a fresh `ClientHandle::session_record()` before49
+the next refresh, refresh tokens only work once4850
4951
`Engine::run` resolves to a `Termination` saying why it stopped, and5052
`Termination::retain` says what to do about it. after `LoggedOut` or51
-`AccessRevoked` the record and the store are dead, bin them. `SoftLoggedOut`52
-wants the store kept and a fresh login with `PasswordLogin::device_id` set to the53
-device it carries, or the e2ee keys go with it. `ShutDown` and `HandlesDropped`54
-keep everything. an `Err` is a real failure and kills nothing. one store55
-directory per login, the sdk won't share a crypto store between devices.56
-The headless example is57
-login-only; it does not persist sessions across runs.53
+`AccessRevoked` the record and the store are dead, `discard` them once the54
+client is dropped, since an open store won't go quietly. `SoftLoggedOut` wants55
+the record forgotten, the store kept and a fresh login with56
+`PasswordLogin::device_id` set to the device it carries, or the e2ee keys go57
+with it. `ShutDown` and `HandlesDropped` keep everything. an `Err` is a real58
+failure and kills nothing. one store directory per login, the sdk won't share a59
+crypto store between devices5860
5961
## wasm6062
@@ -165,11 +165,18 @@ already sent and nothing says when that backlog is gone
165165
with an encrypted store, refresh tokens and threads. the store is sqlite166166
natively and indexeddb in a browser. secret-bearing inputs don't derive `Clone`167167
168
-the caller owns the saved record. save `Session::record()` after login and169
-replace it atomically with `ClientHandle::session_record()` on every170
-`SessionTokensChanged`. logout and a hard rejection kill the record and the171
-store. a soft logout keeps the store for a same-device login, or the e2ee keys172
-go with it. a bare `Stopped` kills nothing. one store directory per login168
+a `SessionStore` owns one login's record and its store. login saves the first169
+record, `restore` loads it, and the caller saves `ClientHandle::session_record()`170
+on every `SessionTokensChanged`. `EncryptedSessionStore` seals the record with171
+the sdk's `StoreCipher` under the store's passphrase and replaces it by rename,172
+so a crash mid save leaves the old one. it keeps the record out of the sdk's173
+state store because sqlite writes custom values there in the clear, and a174
+client can't open its store before it knows the homeserver the record holds.175
+`discard` deletes the record, then the store, since a record without its store176
+would restore into empty crypto. `forget` deletes only the record. the host177
+follows `Termination::retain`, `Nothing` discards and `Store` forgets, keeping178
+the store for a same-device login or the e2ee keys go with it. one store179
+directory per login173180
174181
## room lists, spaces and profiles175182
@@ -1,30 +1,30 @@
11
use macltok::{2
- EngineConfig, Event, PasswordLogin, Retain, RoomMembership, Session, StoreConfig,3
- TimelineContent, TimelineDiff, TimelineId, TimelineItem, imbl::Vector,2
+ EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Retain, RoomMembership, Session,3
+ SessionStore, StoreConfig, TimelineContent, TimelineDiff, TimelineId, TimelineItem,4
+ imbl::Vector,45
};56
67
#[tokio::main]78
async fn main() -> Result<(), Box<dyn std::error::Error>> {89
let store_path = std::path::PathBuf::from(required("MATRIX_STORE_PATH")?);910
let passphrase = required("MATRIX_STORE_PASSPHRASE")?;10
- // a real client keeps this somewhere safer, it holds the access token11
- let record_path = store_path.join("session.json");12
- let store = StoreConfig::sqlite(&store_path, &passphrase);13
- let session = match std::fs::read(&record_path) {14
- Ok(saved) => Session::restore(serde_json::from_slice(&saved)?, store).await?,15
- Err(_) => {16
- let session = Session::login_password(11
+ let sessions = EncryptedSessionStore::file(12
+ store_path.with_extension("session"),13
+ StoreConfig::sqlite(&store_path, &passphrase),14
+ );15
+ let session = match Session::restore(&sessions).await? {16
+ Some(session) => session,17
+ None => {18
+ Session::login_password(1719
PasswordLogin::new(1820
required("MATRIX_HOMESERVER")?,1921
required("MATRIX_USERNAME")?,2022
required("MATRIX_PASSWORD")?,2123
)2224
.with_device_display_name("macltok headless example"),23
- store,25
+ &sessions,2426
)25
- .await?;26
- std::fs::write(&record_path, serde_json::to_vec(&session.record())?)?;27
- session27
+ .await?2828
}2929
};3030
let (engine, handle, mut events) = session.build(EngineConfig::default()).await?;@@ -75,7 +75,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
7575
print_messages(&items);7676
}7777
Event::SessionTokensChanged => {78
- std::fs::write(&record_path, serde_json::to_vec(&handle.session_record()?)?)?;78
+ sessions.save(&handle.session_record()?).await?;7979
}8080
Event::Stopped => break,8181
_ => {}@@ -88,10 +88,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8888
}8989
}9090
}91
- match running.await??.retain() {91
+ let termination = running.await??;92
+ drop((handle, events));93
+ match termination.retain() {9294
Retain::RecordAndStore => {}93
- Retain::Store => std::fs::remove_file(&record_path)?,94
- Retain::Nothing => std::fs::remove_dir_all(&store_path)?,95
+ Retain::Store => sessions.forget().await?,96
+ Retain::Nothing => sessions.discard().await?,9597
}9698
Ok(())9799
}@@ -1,6 +1,6 @@
1
-use std::collections::HashMap;21
#[cfg(feature = "sqlite")]32
use std::path::PathBuf;3
+use std::{collections::HashMap, sync::Arc};44
55
use matrix_sdk::{66
Client, ClientBuilder, SendOutsideWasm, SyncOutsideWasm, ThreadingSupport,@@ -20,11 +20,12 @@ use serde::{Deserialize, Serialize};
2020
use thiserror::Error;2121
use url::Url;2222
23
-use crate::{ClientHandle, Engine, EngineConfig, EngineError, EventStream, error::SourceError};23
+use crate::{24
+ ClientHandle, Engine, EngineConfig, EngineError, EventStream, SessionStore, error::SourceError,25
+};2426
2527
pub struct Session {2628
client: Client,27
- record: SessionRecord,2829
}2930
3031
#[non_exhaustive]@@ -125,6 +126,7 @@ pub enum OAuthApplicationType {
125126
126127
pub struct PendingOAuthLogin {127128
client: Client,129
+ sessions: Arc<dyn SessionStore>,128130
authorization_url: String,129131
state: Option<CsrfToken>,130132
}@@ -162,7 +164,7 @@ impl PendingOAuthLogin {
162164
oauth.abort_login(&state).await;163165
return Err(account_error("OAuth login", error));164166
}165
- Session::from_client(self.client.clone())167
+ Session::saved(self.client.clone(), &*self.sessions).await166168
}167169
168170
pub async fn abort(mut self) {@@ -212,11 +214,33 @@ impl StoreConfig {
212214
self.lock_holder = Some(holder.into());213215
self214216
}217
+218
+ /// delete the store's databases, once nothing has them open219
+ pub async fn delete(&self) -> Result<(), AccountError> {220
+ match &self.location {221
+ #[cfg(feature = "sqlite")]222
+ StoreLocation::Sqlite(path) => crate::session_store::delete_sqlite_store(path)223
+ .map_err(|error| account_error("deleting the Matrix store", error)),224
+ #[cfg(feature = "indexeddb")]225
+ StoreLocation::IndexedDb(name) => crate::session_store::delete_indexeddb_store(name)226
+ .await227
+ .map_err(|error| account_error("deleting the Matrix store", error)),228
+ }229
+ }230
+231
+ pub(crate) fn passphrase(&self) -> &str {232
+ &self.passphrase233
+ }234
+235
+ #[cfg(feature = "indexeddb")]236
+ pub(crate) fn indexeddb_name(&self) -> &str {237
+ let StoreLocation::IndexedDb(name) = &self.location;238
+ name239
+ }215240
}216241
217
-/// Serializable authentication secrets. Store this separately from the218
-/// encrypted store in protected, preferably encrypted storage.219
-/// A saved record must be replaced when `Event::SessionTokensChanged` arrives.242
+/// Serializable authentication secrets. A `SessionStore` keeps it, and it must243
+/// be replaced when `Event::SessionTokensChanged` arrives.220244
#[derive(Clone, Deserialize, Serialize)]221245
pub struct SessionRecord {222246
homeserver: String,@@ -261,9 +285,9 @@ pub enum AccountError {
261285
impl Session {262286
pub async fn login_password(263287
login: PasswordLogin,264
- store: StoreConfig,288
+ sessions: &dyn SessionStore,265289
) -> Result<Self, AccountError> {266
- let client = matrix_client_builder(&store)290
+ let client = matrix_client_builder(sessions.store())267291
.server_name_or_homeserver_url(login.homeserver.trim())268292
.build()269293
.await@@ -282,18 +306,18 @@ impl Session {
282306
.send()283307
.await284308
.map_err(|error| account_error("password login", error))?;285
- Self::from_client(client)309
+ Self::saved(client, sessions).await286310
}287311
288312
pub async fn begin_oauth(289313
login: OAuthLogin,290
- store: StoreConfig,314
+ sessions: Arc<dyn SessionStore>,291315
) -> Result<PendingOAuthLogin, AccountError> {292316
let redirect_uri = Url::parse(login.redirect_uri.trim())293317
.map_err(|error| account_error("parsing the OAuth redirect URI", error))?;294318
let client_uri = Url::parse(login.client_uri.trim())295319
.map_err(|error| account_error("parsing the OAuth client URI", error))?;296
- let client = matrix_client_builder(&store)320
+ let client = matrix_client_builder(sessions.store())297321
.server_name_or_homeserver_url(login.homeserver.trim())298322
.build()299323
.await@@ -327,20 +351,24 @@ impl Session {
327351
.map_err(|error| account_error("starting OAuth login", error))?;328352
Ok(PendingOAuthLogin {329353
client,354
+ sessions,330355
authorization_url: authorization.url.to_string(),331356
state: Some(authorization.state),332357
})333358
}334359
335
- /// Restore a saved record with the same store location and passphrase.360
+ /// Restore the record `sessions` holds, or `None` when it holds none.336361
/// The caller must also handle refresh, rejection, and explicit logout.337
- pub async fn restore(record: SessionRecord, store: StoreConfig) -> Result<Self, AccountError> {338
- let client = matrix_client_builder(&store)362
+ pub async fn restore(sessions: &dyn SessionStore) -> Result<Option<Self>, AccountError> {363
+ let Some(record) = sessions.load().await? else {364
+ return Ok(None);365
+ };366
+ let client = matrix_client_builder(sessions.store())339367
.homeserver_url(&record.homeserver)340368
.build()341369
.await342370
.map_err(|error| account_error("reopening the Matrix store", error))?;343
- let authentication = match record.authentication.clone() {371
+ let authentication = match record.authentication {344372
AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),345373
AuthenticationRecord::OAuth { client_id, user } => {346374
OAuthSession { client_id, user }.into()@@ -350,14 +378,7 @@ impl Session {
350378
.restore_session(authentication)351379
.await352380
.map_err(|error| account_error("restoring the authenticated session", error))?;353
- Ok(Self { client, record })354
- }355
-356
- /// Initial login credentials only. This snapshot does not track later357
- /// token rotations; use `ClientHandle::session_record` after building.358
- #[must_use]359
- pub fn record(&self) -> SessionRecord {360
- self.record.clone()381
+ Ok(Some(Self { client }))361382
}362383
363384
pub async fn build(@@ -369,9 +390,9 @@ impl Session {
369390
.map_err(AccountError::from)370391
}371392
372
- fn from_client(client: Client) -> Result<Self, AccountError> {373
- let record = session_record(&client)?;374
- Ok(Self { client, record })393
+ async fn saved(client: Client, sessions: &dyn SessionStore) -> Result<Self, AccountError> {394
+ sessions.save(&session_record(&client)?).await?;395
+ Ok(Self { client })375396
}376397
}377398
@@ -416,7 +437,7 @@ fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {
416437
}417438
}418439
419
-fn account_error(440
+pub(crate) fn account_error(420441
context: &'static str,421442
source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,422443
) -> AccountError {@@ -16,12 +16,16 @@ compile_error!("indexeddb only exists in a browser, use sqlite");
1616
1717
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]1818
mod account;19
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]20
+mod session_store;1921
2022
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]2123
pub use account::{2224
AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Session,2325
SessionRecord, StoreConfig,2426
};27
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]28
+pub use session_store::{EncryptedSessionStore, SessionStore};2529
2630
pub use engine::{Engine, EngineConfig, EngineError};2731
pub use error::{Error, ErrorKind};@@ -0,0 +1,356 @@
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 tokens16
+/// and each works once, so a `save` has to land before the next refresh or the login is gone17
+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 nothing23
+ 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 open29
+ fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>>;30
+}31
+32
+/// keeps the record sealed with the store's passphrase, in a file natively and in an33
+/// IndexedDB database of its own in a browser34
+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
+ .await72
+ .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
+ .await90
+ .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
+ .await97
+ .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().await105
+ }106
+}107
+108
+impl SessionStore for EncryptedSessionStore {109
+ fn store(&self) -> &StoreConfig {110
+ &self.store111
+ }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 = cipher135
+ .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 = self142
+ .cipher143
+ .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) = rest164
+ .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 = cipher169
+ .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
+ written208
+ }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 synced246
+ #[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
+ .await280
+ }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> = database285
+ .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 = database297
+ .transaction(RECORDS)298
+ .with_mode(TransactionMode::Readwrite)299
+ .build()?;300
+ transaction301
+ .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 private318
+ 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 name345
+ 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
+}@@ -1,13 +1,16 @@
11
#![cfg(feature = "sqlite")]22
mod support;33
4
-use std::time::SystemTime;4
+use std::{path::PathBuf, time::SystemTime};55
6
-use macltok::{EngineConfig, Event, PasswordLogin, Session, SessionRecord, StoreConfig};6
+use macltok::{7
+ EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Session, SessionRecord,8
+ SessionStore, StoreConfig,9
+};710
use matrix_sdk::{ruma::api::client::sync::sync_events::v5, test_utils::mocks::MatrixMockServer};811
912
#[tokio::test]10
-async fn password_session_round_trips_through_an_encrypted_store() {13
+async fn password_session_round_trips_and_discards_with_its_store() {1114
let server = MatrixMockServer::new().await;1215
server1316
.mock_versions()@@ -21,23 +24,23 @@ async fn password_session_round_trips_through_an_encrypted_store() {
2124
.ok(v5::Response::new("1".to_owned()))2225
.mount()2326
.await;24
- let store_path = temporary_store_path();25
- std::fs::create_dir_all(&store_path).unwrap();27
+ let root = temporary_path();28
+ let store_path = root.join("store");29
+ let record_path = root.join("records").join("session");30
+ let sessions = || {31
+ EncryptedSessionStore::file(32
+ &record_path,33
+ StoreConfig::sqlite(&store_path, "test store passphrase"),34
+ )35
+ };2636
let config = EngineConfig::default().with_initial_history_limit(0);2737
let session = Session::login_password(2838
PasswordLogin::new(server.uri(), "example", "wordpass")2939
.with_device_display_name("macltok test"),30
- StoreConfig::sqlite(&store_path, "test store passphrase"),40
+ &sessions(),3141
)3242
.await3343
.unwrap();34
- let encoded = serde_json::to_vec(&session.record()).unwrap();35
- let record: SessionRecord = serde_json::from_slice(&encoded).unwrap();36
- assert_eq!(37
- record.homeserver().trim_end_matches('/'),38
- server.uri().trim_end_matches('/')39
- );40
-4144
let (engine, handle, mut events) = session.build(config.clone()).await.unwrap();4245
let running = tokio::spawn(engine.run());4346
support::receive_until(&mut events, |event| {@@ -45,13 +48,16 @@ async fn password_session_round_trips_through_an_encrypted_store() {
4548
})4649
.await;4750
support::shut_down(&handle, &mut events, running).await;51
+ drop(handle);4852
49
- let session = Session::restore(50
- record,51
- StoreConfig::sqlite(&store_path, "test store passphrase"),52
- )53
- .await54
- .unwrap();53
+ let wrong = EncryptedSessionStore::file(54
+ &record_path,55
+ StoreConfig::sqlite(&store_path, "someone else's passphrase"),56
+ );57
+ assert!(wrong.load().await.is_err());58
+59
+ let sessions = sessions();60
+ let session = Session::restore(&sessions).await.unwrap().unwrap();5561
let (engine, handle, mut events) = session.build(config).await.unwrap();5662
let running = tokio::spawn(engine.run());5763
support::receive_until(&mut events, |event| {@@ -59,14 +65,68 @@ async fn password_session_round_trips_through_an_encrypted_store() {
5965
})6066
.await;6167
support::shut_down(&handle, &mut events, running).await;68
+ drop(handle);69
+70
+ sessions.discard().await.unwrap();71
+ assert!(!record_path.exists());72
+ assert!(!store_path.exists());73
+ assert!(Session::restore(&sessions).await.unwrap().is_none());74
+ std::fs::remove_dir_all(root).unwrap();75
+}76
+77
+#[tokio::test]78
+async fn a_torn_or_failed_save_leaves_the_old_record() {79
+ let root = temporary_path();80
+ let record_path = root.join("session");81
+ let sessions = || {82
+ EncryptedSessionStore::file(83
+ &record_path,84
+ StoreConfig::sqlite(root.join("store"), "test store passphrase"),85
+ )86
+ };87
+ let temporary = root.join("session.tmp");88
+ sessions()89
+ .save(&record("https://old.example"))90
+ .await91
+ .unwrap();92
+93
+ std::fs::write(&temporary, b"half a rec").unwrap();94
+ let loaded = sessions().load().await.unwrap().unwrap();95
+ assert_eq!(loaded.homeserver(), "https://old.example");96
+97
+ std::fs::remove_file(&temporary).unwrap();98
+ std::fs::create_dir(&temporary).unwrap();99
+ assert!(100
+ sessions()101
+ .save(&record("https://new.example"))102
+ .await103
+ .is_err()104
+ );105
+ let loaded = sessions().load().await.unwrap().unwrap();106
+ assert_eq!(loaded.homeserver(), "https://old.example");107
+108
+ std::fs::remove_dir_all(root).unwrap();109
+}62110
63
- std::fs::remove_dir_all(store_path).unwrap();111
+fn record(homeserver: &str) -> SessionRecord {112
+ serde_json::from_value(serde_json::json!({113
+ "homeserver": homeserver,114
+ "authentication": {115
+ "type": "matrix",116
+ "session": {117
+ "user_id": "@example:example.org",118
+ "device_id": "DEVICE",119
+ "access_token": "secret",120
+ },121
+ },122
+ }))123
+ .unwrap()64124
}65125
66
-fn temporary_store_path() -> std::path::PathBuf {126
+fn temporary_path() -> PathBuf {67127
let nonce = SystemTime::now()68128
.duration_since(SystemTime::UNIX_EPOCH)69129
.unwrap()70130
.as_nanos();71
- std::env::temp_dir().join(format!("macltok-account-{nonce}"))131
+ PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("macltok-account-{nonce}"))72132
}