atagen

macltok

public
Star0
Watch0

Commit 6f1d6591c10d

tarball

atagen <boss@atagen.co>2026-09-27 01:42 UTC

8 files changed+177-47
account: remember the device across a soft logout

Deleting the record on Retain::Store left the device id only in memory, so a host restarting before the re-login saw no record and tried a fresh login on a store holding another device's keys. SessionStore::soft_log_out now keeps a tokenless record naming the homeserver, user, device and OAuth client, and Session::restore answers it as Restore::SoftLoggedOut instead of None.
Modified file: README.md+7 additions−5 deletions
@@ -52,11 +52,13 @@ the next refresh, refresh tokens only work once
5252 `Termination::retain` says what to do about it. after `LoggedOut` or
5353 `AccessRevoked` the record and the store are dead, `discard` them once the
5454 client is dropped, since an open store won't go quietly. `SoftLoggedOut` wants
55-the record forgotten, the store kept and a fresh login with
56-`PasswordLogin::device_id` set to the device it carries, or the e2ee keys go
57-with it. `ShutDown` and `HandlesDropped` keep everything. an `Err` is a real
58-failure and kills nothing. one store directory per login, the sdk won't share a
59-crypto store between devices
55+the store kept and a fresh login as the same device, or the e2ee keys go with
56+it. `soft_log_out` swaps the record for one naming that device, so a restart
57+gets `Restore::SoftLoggedOut` and `SoftLogout::password_login` rather than
58+nothing, and the login overwrites it with a live record. `ShutDown` and
59+`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 between
61+devices
6062
6163 ## wasm
6264
Modified file: docs/design-decisions.md+6 additions−4 deletions
@@ -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 a
174174 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 store
176-would restore into empty crypto. `forget` deletes only the record. the host
177-follows `Termination::retain`, `Nothing` discards and `Store` forgets, keeping
178-the store for a same-device login or the e2ee keys go with it. one store
179-directory per login
176+would restore into empty crypto. the host follows `Termination::retain`.
177+`Nothing` discards. `Store` calls `soft_log_out`, which keeps the store and
178+swaps the record for one holding the homeserver, user, device and oauth client
179+id with no tokens. `restore` hands that back as `Restore::SoftLoggedOut`, so a
180+restart still knows which device to log in again as, and the login overwrites
181+it. one store directory per login
180182
181183 ## room lists, spaces and profiles
182184
Modified file: examples/headless.rs+8 additions−4 deletions
@@ -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(())
Modified file: src/account.rs+66 additions−9 deletions
@@ -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.homeserver
264268 }
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 login
297+/// as any other device would orphan them
298+#[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- .await
370- .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+ .await
433+ .map_err(|error| account_error("reopening the Matrix store", error))?;
377434 client
378435 .restore_session(authentication)
379436 .await
380437 .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(
Modified file: src/lib.rs+2 additions−2 deletions
@@ -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};
Modified file: src/model.rs+1 additions
@@ -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 as
747748 Store,
748749 Nothing,
749750 }
Modified file: src/session_store.rs+40 additions−21 deletions
@@ -22,8 +22,9 @@ pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {
2222 /// replace the saved record, all or nothing
2323 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 restart
27+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>>;
2728
2829 /// drop the record and delete the store, once nothing has the store open
2930 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).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> {
70100 let Some(sealed) = backend::read(&self.record)
71101 .await
72102 .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 .await
90123 .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- .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- }
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>> {
Modified file: tests/account.rs+47 additions−2 deletions
@@ -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+ .await
96+ .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 out
103+ assert_eq!(soft.device_id, "GHTYAJCE");
104+ Session::login_password(soft.password_login("wordpass"), &sessions())
105+ .await
106+ .unwrap();
107+ let requests = server.server().received_requests().await.unwrap();
108+ let relogin: serde_json::Value = requests
109+ .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");