atagen

macltok

public
Star0
Watch0

Commit 713205a95af2

tarball

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

9 files changed+547-85
account: keep the session record behind a SessionStore

Refresh tokens work once, so every host had to hand-roll an atomic encrypted save on SessionTokensChanged and delete the record and the store to match Termination::retain. The record stays out of the sdk's state store because matrix-sdk-sqlite writes custom values unencrypted and a client can't open its store before it knows the homeserver the record holds, so EncryptedSessionStore seals it with a StoreCipher under the store's passphrase. Login and restore take the SessionStore so the first record can't go unsaved.
Modified file: Cargo.lock+2 additions
@@ -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",
Modified file: Cargo.toml+10 additions−2 deletions
@@ -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
Modified file: README.md+13 additions−11 deletions
@@ -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 it
4242 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 from
46-`ClientHandle::session_record()` and replace the saved record atomically in
47-protected storage. Restore with the same store location and passphrase.
44+with `sqlite` or `indexeddb`, a `SessionStore` holds one login's record and its
45+store. `EncryptedSessionStore` seals the record with the store's passphrase, in a
46+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 every
48+`SessionTokensChanged`, `save` a fresh `ClientHandle::session_record()` before
49+the next refresh, refresh tokens only work once
4850
4951 `Engine::run` resolves to a `Termination` saying why it stopped, and
5052 `Termination::retain` says what to do about it. after `LoggedOut` or
51-`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 the
53-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 store
55-directory per login, the sdk won't share a crypto store between devices.
56-The headless example is
57-login-only; it does not persist sessions across runs.
53+`AccessRevoked` the record and the store are dead, `discard` them once the
54+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
5860
5961 ## wasm
6062
Modified file: docs/design-decisions.md+12 additions−5 deletions
@@ -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 sqlite
166166 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 and
169-replace it atomically with `ClientHandle::session_record()` on every
170-`SessionTokensChanged`. logout and a hard rejection kill the record and the
171-store. a soft logout keeps the store for a same-device login, or the e2ee keys
172-go with it. a bare `Stopped` kills nothing. one store directory per login
168+a `SessionStore` owns one login's record and its store. login saves the first
169+record, `restore` loads it, and the caller saves `ClientHandle::session_record()`
170+on every `SessionTokensChanged`. `EncryptedSessionStore` seals the record with
171+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's
173+state store because sqlite writes custom values there in the clear, and a
174+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 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
173180
174181 ## room lists, spaces and profiles
175182
Modified file: examples/headless.rs+19 additions−17 deletions
@@ -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 token
11- 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- session
27+ .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 }
Modified file: src/account.rs+49 additions−28 deletions
@@ -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).await
166168 }
167169
168170 pub async fn abort(mut self) {
@@ -212,11 +214,33 @@ impl StoreConfig {
212214 self.lock_holder = Some(holder.into());
213215 self
214216 }
217+
218+ /// delete the store's databases, once nothing has them open
219+ 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+ .await
227+ .map_err(|error| account_error("deleting the Matrix store", error)),
228+ }
229+ }
230+
231+ pub(crate) fn passphrase(&self) -> &str {
232+ &self.passphrase
233+ }
234+
235+ #[cfg(feature = "indexeddb")]
236+ pub(crate) fn indexeddb_name(&self) -> &str {
237+ let StoreLocation::IndexedDb(name) = &self.location;
238+ name
239+ }
215240 }
216241
217-/// Serializable authentication secrets. Store this separately from the
218-/// 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 must
243+/// 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 .await
284308 .map_err(|error| account_error("password login", error))?;
285- Self::from_client(client)
309+ Self::saved(client, sessions).await
286310 }
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 .await
342370 .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 .await
352380 .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 later
357- /// 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 {
Modified file: src/lib.rs+4 additions
@@ -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};
Added file: src/session_store.rs+356 additions
@@ -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 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+}
Modified file: tests/account.rs+82 additions−22 deletions
@@ -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 server
1316 .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 .await
3343 .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- .await
54- .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+ .await
91+ .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+ .await
103+ .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 }