Commit 6f1d6591c10d
tarball8 files changed+177-47
@@ -52,11 +52,13 @@ the next refresh, refresh tokens only work once
5252
`Termination::retain` says what to do about it. after `LoggedOut` or5353
`AccessRevoked` the record and the store are dead, `discard` them once the5454
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 devices55
+the store kept and a fresh login as the same device, or the e2ee keys go with56
+it. `soft_log_out` swaps the record for one naming that device, so a restart57
+gets `Restore::SoftLoggedOut` and `SoftLogout::password_login` rather than58
+nothing, and the login overwrites it with a live record. `ShutDown` and59
+`HandlesDropped` keep everything. an `Err` is a real failure and kills nothing.60
+one store directory per login, the sdk won't share a crypto store between61
+devices6062
6163
## wasm6264
@@ -173,10 +173,12 @@ so a crash mid save leaves the old one. it keeps the record out of the sdk's
173173
state store because sqlite writes custom values there in the clear, and a174174
client can't open its store before it knows the homeserver the record holds.175175
`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 login176
+would restore into empty crypto. the host follows `Termination::retain`.177
+`Nothing` discards. `Store` calls `soft_log_out`, which keeps the store and178
+swaps the record for one holding the homeserver, user, device and oauth client179
+id with no tokens. `restore` hands that back as `Restore::SoftLoggedOut`, so a180
+restart still knows which device to log in again as, and the login overwrites181
+it. one store directory per login180182
181183
## room lists, spaces and profiles182184
@@ -1,6 +1,6 @@
11
use macltok::{2
- EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Retain, RoomMembership, Session,3
- SessionStore, StoreConfig, TimelineContent, TimelineDiff, TimelineId, TimelineItem,2
+ EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Retain, RoomMembership,3
+ Session, SessionStore, StoreConfig, TimelineContent, TimelineDiff, TimelineId, TimelineItem,44
imbl::Vector,55
};66
@@ -13,7 +13,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
1313
StoreConfig::sqlite(&store_path, &passphrase),1414
);1515
let session = match Session::restore(&sessions).await? {16
- Some(session) => session,16
+ Some(Restore::Resumed(session)) => session,17
+ Some(Restore::SoftLoggedOut(soft)) => {18
+ Session::login_password(soft.password_login(required("MATRIX_PASSWORD")?), &sessions)19
+ .await?20
+ }1721
None => {1822
Session::login_password(1923
PasswordLogin::new(@@ -92,7 +96,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
9296
drop((handle, events));9397
match termination.retain() {9498
Retain::RecordAndStore => {}95
- Retain::Store => sessions.forget().await?,99
+ Retain::Store => sessions.soft_log_out().await?,96100
Retain::Nothing => sessions.discard().await?,97101
}98102
Ok(())@@ -3,7 +3,7 @@ use std::path::PathBuf;
33
use std::{collections::HashMap, sync::Arc};44
55
use matrix_sdk::{6
- Client, ClientBuilder, SendOutsideWasm, SyncOutsideWasm, ThreadingSupport,6
+ Client, ClientBuilder, SendOutsideWasm, SessionMeta, SyncOutsideWasm, ThreadingSupport,77
authentication::{88
AuthSession,99
matrix::MatrixSession,@@ -14,7 +14,7 @@ use matrix_sdk::{
1414
},1515
cross_process_lock::CrossProcessLockConfig,1616
encryption::{BackupDownloadStrategy, EncryptionSettings},17
- ruma::{OwnedDeviceId, serde::Raw},17
+ ruma::{OwnedDeviceId, OwnedUserId, serde::Raw},1818
};1919
use serde::{Deserialize, Serialize};2020
use thiserror::Error;@@ -255,6 +255,10 @@ enum AuthenticationRecord {
255255
client_id: ClientId,256256
user: UserSession,257257
},258
+ SoftLoggedOut {259
+ meta: SessionMeta,260
+ client_id: Option<ClientId>,261
+ },258262
}259263
260264
impl SessionRecord {@@ -262,6 +266,51 @@ impl SessionRecord {
262266
pub fn homeserver(&self) -> &str {263267
&self.homeserver264268
}269
+270
+ pub(crate) fn soft_logged_out(self) -> Self {271
+ let authentication = match self.authentication {272
+ AuthenticationRecord::Matrix(session) => AuthenticationRecord::SoftLoggedOut {273
+ meta: session.meta,274
+ client_id: None,275
+ },276
+ AuthenticationRecord::OAuth { client_id, user } => {277
+ AuthenticationRecord::SoftLoggedOut {278
+ meta: user.meta,279
+ client_id: Some(client_id),280
+ }281
+ }282
+ soft @ AuthenticationRecord::SoftLoggedOut { .. } => soft,283
+ };284
+ Self {285
+ homeserver: self.homeserver,286
+ authentication,287
+ }288
+ }289
+}290
+291
+pub enum Restore {292
+ Resumed(Session),293
+ SoftLoggedOut(SoftLogout),294
+}295
+296
+/// who to log in again as. the store keeps this device's e2ee keys, so a login297
+/// as any other device would orphan them298
+#[derive(Clone, Debug)]299
+#[non_exhaustive]300
+pub struct SoftLogout {301
+ pub homeserver: String,302
+ pub user_id: OwnedUserId,303
+ pub device_id: OwnedDeviceId,304
+ /// set when the login was OAuth, for `OAuthLogin::with_client_id`305
+ pub client_id: Option<String>,306
+}307
+308
+impl SoftLogout {309
+ #[must_use]310
+ pub fn password_login(&self, password: impl Into<String>) -> PasswordLogin {311
+ PasswordLogin::new(self.homeserver.clone(), self.user_id.as_str(), password)312
+ .with_device_id(self.device_id.clone())313
+ }265314
}266315
267316
#[derive(Debug, Error)]@@ -359,26 +408,34 @@ impl Session {
359408
360409
/// Restore the record `sessions` holds, or `None` when it holds none.361410
/// The caller must also handle refresh, rejection, and explicit logout.362
- pub async fn restore(sessions: &dyn SessionStore) -> Result<Option<Self>, AccountError> {411
+ pub async fn restore(sessions: &dyn SessionStore) -> Result<Option<Restore>, AccountError> {363412
let Some(record) = sessions.load().await? else {364413
return Ok(None);365414
};366
- let client = matrix_client_builder(sessions.store())367
- .homeserver_url(&record.homeserver)368
- .build()369
- .await370
- .map_err(|error| account_error("reopening the Matrix store", error))?;371415
let authentication = match record.authentication {372416
AuthenticationRecord::Matrix(session) => AuthSession::Matrix(session),373417
AuthenticationRecord::OAuth { client_id, user } => {374418
OAuthSession { client_id, user }.into()375419
}420
+ AuthenticationRecord::SoftLoggedOut { meta, client_id } => {421
+ return Ok(Some(Restore::SoftLoggedOut(SoftLogout {422
+ homeserver: record.homeserver,423
+ user_id: meta.user_id,424
+ device_id: meta.device_id,425
+ client_id: client_id.map(|client_id| client_id.as_str().to_owned()),426
+ })));427
+ }376428
};429
+ let client = matrix_client_builder(sessions.store())430
+ .homeserver_url(&record.homeserver)431
+ .build()432
+ .await433
+ .map_err(|error| account_error("reopening the Matrix store", error))?;377434
client378435
.restore_session(authentication)379436
.await380437
.map_err(|error| account_error("restoring the authenticated session", error))?;381
- Ok(Some(Self { client }))438
+ Ok(Some(Restore::Resumed(Self { client })))382439
}383440
384441
pub async fn build(@@ -21,8 +21,8 @@ mod session_store;
2121
2222
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]2323
pub use account::{24
- AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Session,25
- SessionRecord, StoreConfig,24
+ AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,25
+ Session, SessionRecord, SoftLogout, StoreConfig,2626
};2727
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]2828
pub use session_store::{EncryptedSessionStore, SessionStore};@@ -744,6 +744,7 @@ impl Termination {
744744
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]745745
pub enum Retain {746746
RecordAndStore,747
+ /// keep the store and a record naming the device to log in again as747748
Store,748749
Nothing,749750
}@@ -22,8 +22,9 @@ pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {
2222
/// replace the saved record, all or nothing2323
fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>>;2424
25
- /// drop the record and keep the store, for `Retain::Store`26
- fn forget(&self) -> BoxFuture<'_, Result<(), AccountError>>;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 restart27
+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>>;2728
2829
/// drop the record and delete the store, once nothing has the store open2930
fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>>;@@ -67,6 +68,35 @@ impl EncryptedSessionStore {
6768
6869
async fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {6970
let mut sealer = self.sealer.lock().await;71
+ self.read(&mut sealer).await72
+ }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).await77
+ }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
+ .await91
+ .map_err(|error| account_error("removing the session record", error))?;92
+ *sealer = None;93
+ self.store.delete().await94
+ }95
+96
+ async fn read(97
+ &self,98
+ sealer: &mut Option<Sealer>,99
+ ) -> Result<Option<SessionRecord>, AccountError> {70100
let Some(sealed) = backend::read(&self.record)71101
.await72102
.map_err(|error| account_error("reading the session record", error))?@@ -78,9 +108,12 @@ impl EncryptedSessionStore {
78108
Ok(Some(record))79109
}80110
81
- async fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {82
- let mut sealer = self.sealer.lock().await;83
- let sealer = match &mut *sealer {111
+ async fn write(112
+ &self,113
+ sealer: &mut Option<Sealer>,114
+ record: &SessionRecord,115
+ ) -> Result<(), AccountError> {116
+ let sealer = match sealer {84117
Some(sealer) => sealer,85118
empty => empty.insert(Sealer::new(self.store.passphrase())?),86119
};@@ -89,20 +122,6 @@ impl EncryptedSessionStore {
89122
.await90123
.map_err(|error| account_error("writing the session record", error))91124
}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
- }106125
}107126
108127
impl SessionStore for EncryptedSessionStore {@@ -118,8 +137,8 @@ impl SessionStore for EncryptedSessionStore {
118137
Box::pin(self.save_record(record))119138
}120139
121
- fn forget(&self) -> BoxFuture<'_, Result<(), AccountError>> {122
- Box::pin(self.forget_record())140
+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {141
+ Box::pin(self.soft_log_out_record())123142
}124143
125144
fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {@@ -4,7 +4,7 @@ mod support;
44
use std::{path::PathBuf, time::SystemTime};55
66
use macltok::{7
- EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Session, SessionRecord,7
+ EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Session, SessionRecord,88
SessionStore, StoreConfig,99
};1010
use matrix_sdk::{ruma::api::client::sync::sync_events::v5, test_utils::mocks::MatrixMockServer};@@ -57,7 +57,9 @@ async fn password_session_round_trips_and_discards_with_its_store() {
5757
assert!(wrong.load().await.is_err());5858
5959
let sessions = sessions();60
- let session = Session::restore(&sessions).await.unwrap().unwrap();60
+ let Some(Restore::Resumed(session)) = Session::restore(&sessions).await.unwrap() else {61
+ panic!("the saved login didn't resume");62
+ };6163
let (engine, handle, mut events) = session.build(config).await.unwrap();6264
let running = tokio::spawn(engine.run());6365
support::receive_until(&mut events, |event| {@@ -75,6 +77,49 @@ async fn password_session_round_trips_and_discards_with_its_store() {
7577
}7678
7779
#[tokio::test]80
+async fn a_restart_after_a_soft_logout_still_knows_the_device() {81
+ let server = MatrixMockServer::new().await;82
+ server.mock_versions().ok().mount().await;83
+ server.mock_login().ok().mount().await;84
+ let root = temporary_path();85
+ let sessions = || {86
+ EncryptedSessionStore::file(87
+ root.join("session"),88
+ StoreConfig::sqlite(root.join("store"), "test store passphrase"),89
+ )90
+ };91
+ Session::login_password(92
+ PasswordLogin::new(server.uri(), "example", "wordpass"),93
+ &sessions(),94
+ )95
+ .await96
+ .unwrap();97
+ sessions().soft_log_out().await.unwrap();98
+99
+ let Some(Restore::SoftLoggedOut(soft)) = Session::restore(&sessions()).await.unwrap() else {100
+ panic!("a soft logged out record came back as something else");101
+ };102
+ // the device matrix-sdk-test's canned login response hands out103
+ assert_eq!(soft.device_id, "GHTYAJCE");104
+ Session::login_password(soft.password_login("wordpass"), &sessions())105
+ .await106
+ .unwrap();107
+ let requests = server.server().received_requests().await.unwrap();108
+ let relogin: serde_json::Value = requests109
+ .iter()110
+ .rfind(|request| request.url.path().ends_with("/login"))111
+ .unwrap()112
+ .body_json()113
+ .unwrap();114
+ assert_eq!(relogin["device_id"], "GHTYAJCE");115
+ assert!(matches!(116
+ Session::restore(&sessions()).await.unwrap(),117
+ Some(Restore::Resumed(_))118
+ ));119
+ std::fs::remove_dir_all(root).unwrap();120
+}121
+122
+#[tokio::test]78123
async fn a_torn_or_failed_save_leaves_the_old_record() {79124
let root = temporary_path();80125
let record_path = root.join("session");