[research] Desktop: store the access token in the OS keychain + idle lock #105

Closed
opened 2026-09-17 01:39:34 -04:00 by jared · 3 comments
Owner

Follow-up to N97 (access token + device id in plaintext localStorage). On the Tauri desktop app this is solvable now: keep the token in the OS keychain (Windows Credential Manager / macOS Keychain / Secret Service) and out of the webview's storage; optionally add an idle lock that re-authenticates via the OS after N minutes.

Research first — do not implement until the findings are reviewed. Required outputs:

  1. Map every read/write of the session in src/app/state/sessions.ts, src/client/initMatrix.ts, src/sw-session.ts (the service worker needs the token for authenticated media — how does it get it if the token is not in localStorage?), the OIDC callback, multi-tab logout sync, and cinny-desktop's Rust side.
  2. Evaluate tauri-plugin-stronghold vs tauri-plugin-keyring/keyring crate; Linux Secret Service availability on headless/portable installs; behaviour when the keychain is locked or denied.
  3. Migration path for existing installs (token currently in localStorage → move → wipe), and rollback.
  4. Whether the rust-crypto IndexedDB store needs the token (it does not — confirm) and how the web PWA remains unchanged.
  5. Threat model note: what this defends against (XSS token exfiltration, disk reads by other users) and what it does not.
    Deliverable: a written plan in this issue; implementation is a separate issue once approved.
Follow-up to N97 (access token + device id in plaintext `localStorage`). On the Tauri desktop app this is solvable now: keep the token in the OS keychain (Windows Credential Manager / macOS Keychain / Secret Service) and out of the webview's storage; optionally add an idle lock that re-authenticates via the OS after N minutes. **Research first — do not implement until the findings are reviewed.** Required outputs: 1. Map every read/write of the session in `src/app/state/sessions.ts`, `src/client/initMatrix.ts`, `src/sw-session.ts` (the service worker needs the token for authenticated media — how does it get it if the token is not in localStorage?), the OIDC callback, multi-tab logout sync, and cinny-desktop's Rust side. 2. Evaluate `tauri-plugin-stronghold` vs `tauri-plugin-keyring`/`keyring` crate; Linux Secret Service availability on headless/portable installs; behaviour when the keychain is locked or denied. 3. Migration path for existing installs (token currently in localStorage → move → wipe), and rollback. 4. Whether the rust-crypto IndexedDB store needs the token (it does not — confirm) and how the web PWA remains unchanged. 5. Threat model note: what this defends against (XSS token exfiltration, disk reads by other users) and what it does not. Deliverable: a written plan in this issue; implementation is a separate issue once approved.
jared added this to the Features 2026-Q4 milestone 2026-09-17 01:39:34 -04:00
jared added the enhancementpriority: highsecurityarea: desktoparea: auth-session labels 2026-09-17 01:39:34 -04:00
jared self-assigned this 2026-09-17 01:39:34 -04:00
Author
Owner

Research findings and plan (no code changed)

TL;DR

Moving just the access token into the OS keychain buys less than it sounds like. The bigger at-rest exposure is the end-to-end-encryption key store: initMatrix.ts:85 calls mx.initRustCrypto() with no storageKey, so device keys, Olm/Megolm sessions and any cached cross-signing/backup secrets sit unencrypted in IndexedDB. Anyone who can read the profile folder gets message history, not just a revocable token. Recommendation: the keychain holds a random 32-byte crypto-store key (passed as initRustCrypto({ storageKey })) plus the tokens. The idle lock is a separate, optional step.

1. Where the session is read and written

Everything goes through src/app/state/sessions.ts: the cinny_session_v1 blob (SESSION_BLOB_KEY, l.52), dual-written to legacy per-field keys and OIDC keys (l.187-212). All of it is synchronous localStorage:

Read (getFallbackSession) Write (setFallbackSession) / clear
index.tsx:32,63,67 (boot, SW registration, storage.persist) loginUtil.ts:114 (password / SSO login)
Router.tsx:122,130,171 (route guards, redirects) registerUtil.ts:122
ClientRoot.tsx:157,195,199 OidcCallback.tsx:33
initMatrix.ts:118,153 oidcTokenRefresher.ts:94 (every token rotation)
oidcTokenRefresher.ts:60 clear: initMatrix.ts:140, ClientRoot.tsx:174, plaintextCaches.ts
useSessionSync.ts:58, useSessionAppeared.ts:23 (react to other tabs via storage events, subscribeSessionChanges l.306)
  • Service worker: it never reads localStorage. The page pushes {accessToken, baseUrl} with postMessage (sw-session.ts → sw.ts:66-68), and the SW asks again with requestSession (sw.ts:82-97). Because the token stays in the page's memory, the SW needs no change.
  • Rust side (cinny-desktop): it doesn't touch the session today. There's no keychain or stronghold dependency.
  • The catch: every read is synchronous. Route guards and index.tsx boot decide on it before React renders. A keychain read is an async Tauri invoke, so the desktop build needs a session preload before routing: resolve the keychain once, cache it in memory, then keep the existing synchronous getFallbackSession() API reading that cache. Only the backing store changes, so the 20+ call sites stay the same.

2. keyring crate vs tauri-plugin-stronghold

  • keyring (v3, used directly from our Rust side; small commands session_get/set/clear): Windows Credential Manager (DPAPI), macOS Keychain, Linux Secret Service over D-Bus. Recommended.
    • Linux caveat: Secret Service needs a running provider (gnome-keyring / KWallet). A headless box or a bare window manager has none. The keyutils fallback is kernel memory and doesn't survive a reboot.
    • So: on any keychain error, fall back to today's localStorage behaviour and flag it (Settings → Privacy: "Session stored in app data: no system keychain available"). Never lock the user out.
  • Stronghold: an encrypted vault file that needs a password. Without asking the user for one, we'd have to store its key somewhere, which is the same problem again. Only worth it if you want a user passphrase for the lock (see 6).
  • Locked or denied keychain (macOS prompt refused, locked GNOME keyring): the read fails. Show "Unlock your system keychain to continue" with a Retry button, then fall back as above. Never silently log out.

3. Migration and rollback

  • Tokens: on first desktop launch with the feature, if the keychain is available and cinny_session_v1 exists, write it to the keychain, read it back, and only then delete the localStorage copies. Rollback: an older build finds no localStorage session and shows the login screen. That's recoverable, but it costs a re-login (and on a new device ID, key backup or verification). To avoid that, keep writing the localStorage copy for one release before switching reads over.
  • Crypto-store key: this is the hard part. An existing unencrypted rust-crypto store can't be re-keyed in place. Options:
    • (a) Apply it to new logins only. Existing installs keep an unencrypted store until their next login. Simple and safe. Recommended.
    • (b) Force a one-time re-login with key-backup restore. Stronger, but disruptive.
      Rollback of an encrypted store to an old build means that build can't open it, so this has to be forward-only, gated behind the keychain being available.

4. Crypto store and the web PWA

  • The rust-crypto IndexedDB store doesn't need the access token. It's keyed by user and device (the matrix-js-sdk:* databases); the token only authenticates HTTP. Confirmed: initRustCrypto() takes no token.
  • The web PWA is unchanged. Everything is behind isTauri(), and the browser has no keychain. (A browser equivalent would be a WebAuthn-PRF-derived storageKey; that's a separate idea.)

5. Threat model

Defends against:

  • An offline copy of the profile: a stolen or unencrypted laptop disk (no BitLocker), backups or sync tools that grab %LOCALAPPDATA%, another OS user with admin rights reading the files. With the crypto-store key in the keychain, those copies no longer contain usable E2EE keys or tokens.
  • Leftovers after logout on a shared machine.

Does not defend against:

  • XSS in the running app. The token has to be in JS memory for MatrixClient, so mx.getAccessToken() still works for injected script. The real defences there are CSP and #43.
  • Malware running as the same user. Credential Manager and Secret Service hand secrets to any same-user process without a prompt. macOS is the exception: it prompts per app.
  • A compromised homeserver.

6. Idle lock (optional, separate issue)

On Windows, UserConsentVerifier (Windows Hello / PIN) via the windows crate is feasible. After N minutes idle, blur the UI behind a lock screen and require the OS prompt. It's a UI lock only: the keys stay in memory. It helps on walk-away / shared machines. macOS would use LocalAuthentication; Linux has no standard, so it'd need a user passphrase (the Stronghold route) or no lock.

Proposed implementation issues (once approved)

  1. Desktop session preload + keychain backing store (keyring), with fallback and one release of dual-write.
  2. storageKey for new logins, from the keychain.
  3. Settings → Privacy status line ("stored in: system keychain / app data").
  4. Optional: Windows Hello idle lock.
## Research findings and plan (no code changed) ### TL;DR Moving just the access token into the OS keychain buys less than it sounds like. The bigger at-rest exposure is the **end-to-end-encryption key store**: `initMatrix.ts:85` calls `mx.initRustCrypto()` with **no `storageKey`**, so device keys, Olm/Megolm sessions and any cached cross-signing/backup secrets sit **unencrypted in IndexedDB**. Anyone who can read the profile folder gets message history, not just a revocable token. **Recommendation:** the keychain holds a random 32-byte **crypto-store key** (passed as `initRustCrypto({ storageKey })`) plus the tokens. The idle lock is a separate, optional step. ### 1. Where the session is read and written Everything goes through `src/app/state/sessions.ts`: the `cinny_session_v1` blob (`SESSION_BLOB_KEY`, l.52), dual-written to legacy per-field keys and OIDC keys (l.187-212). All of it is **synchronous `localStorage`**: | Read (`getFallbackSession`) | Write (`setFallbackSession`) / clear | |---|---| | `index.tsx:32,63,67` (boot, SW registration, `storage.persist`) | `loginUtil.ts:114` (password / SSO login) | | `Router.tsx:122,130,171` (route guards, redirects) | `registerUtil.ts:122` | | `ClientRoot.tsx:157,195,199` | `OidcCallback.tsx:33` | | `initMatrix.ts:118,153` | `oidcTokenRefresher.ts:94` (every token rotation) | | `oidcTokenRefresher.ts:60` | clear: `initMatrix.ts:140`, `ClientRoot.tsx:174`, `plaintextCaches.ts` | | `useSessionSync.ts:58`, `useSessionAppeared.ts:23` (react to other tabs via `storage` events, `subscribeSessionChanges` l.306) | | - **Service worker:** it never reads `localStorage`. The page pushes `{accessToken, baseUrl}` with `postMessage` (`sw-session.ts` → `sw.ts:66-68`), and the SW asks again with `requestSession` (`sw.ts:82-97`). Because the token stays in the page's memory, **the SW needs no change**. - **Rust side (cinny-desktop):** it doesn't touch the session today. There's no keychain or stronghold dependency. - **The catch:** every read is synchronous. Route guards and `index.tsx` boot decide on it before React renders. A keychain read is an async Tauri `invoke`, so the desktop build needs a **session preload** before routing: resolve the keychain once, cache it in memory, then keep the existing synchronous `getFallbackSession()` API reading that cache. Only the backing store changes, so the 20+ call sites stay the same. ### 2. `keyring` crate vs `tauri-plugin-stronghold` - **`keyring` (v3, used directly from our Rust side; small commands `session_get/set/clear`):** Windows Credential Manager (DPAPI), macOS Keychain, Linux Secret Service over D-Bus. **Recommended.** - Linux caveat: Secret Service needs a running provider (gnome-keyring / KWallet). A headless box or a bare window manager has none. The `keyutils` fallback is kernel memory and doesn't survive a reboot. - So: **on any keychain error, fall back to today's `localStorage` behaviour and flag it** (Settings → Privacy: "Session stored in app data: no system keychain available"). Never lock the user out. - **Stronghold:** an encrypted vault file that needs a password. Without asking the user for one, we'd have to store its key somewhere, which is the same problem again. Only worth it if you want a **user passphrase** for the lock (see 6). - **Locked or denied keychain** (macOS prompt refused, locked GNOME keyring): the read fails. Show "Unlock your system keychain to continue" with a Retry button, then fall back as above. Never silently log out. ### 3. Migration and rollback - **Tokens:** on first desktop launch with the feature, if the keychain is available and `cinny_session_v1` exists, write it to the keychain, **read it back**, and only then delete the `localStorage` copies. Rollback: an older build finds no `localStorage` session and shows the login screen. That's recoverable, but it costs a re-login (and on a new device ID, key backup or verification). To avoid that, keep writing the `localStorage` copy for **one release** before switching reads over. - **Crypto-store key:** this is the hard part. An existing unencrypted rust-crypto store **can't be re-keyed in place**. Options: - (a) Apply it to **new logins only**. Existing installs keep an unencrypted store until their next login. Simple and safe. **Recommended.** - (b) Force a one-time re-login with key-backup restore. Stronger, but disruptive. Rollback of an encrypted store to an old build means that build can't open it, so this has to be **forward-only**, gated behind the keychain being available. ### 4. Crypto store and the web PWA - The rust-crypto IndexedDB store **doesn't need the access token**. It's keyed by user and device (the `matrix-js-sdk:*` databases); the token only authenticates HTTP. Confirmed: `initRustCrypto()` takes no token. - **The web PWA is unchanged.** Everything is behind `isTauri()`, and the browser has no keychain. (A browser equivalent would be a WebAuthn-PRF-derived `storageKey`; that's a separate idea.) ### 5. Threat model **Defends against:** - An offline copy of the profile: a stolen or unencrypted laptop disk (no BitLocker), backups or sync tools that grab `%LOCALAPPDATA%`, another OS user with admin rights reading the files. With the crypto-store key in the keychain, those copies no longer contain usable E2EE keys or tokens. - Leftovers after logout on a shared machine. **Does not defend against:** - **XSS in the running app.** The token has to be in JS memory for `MatrixClient`, so `mx.getAccessToken()` still works for injected script. The real defences there are CSP and #43. - **Malware running as the same user.** Credential Manager and Secret Service hand secrets to any same-user process without a prompt. macOS is the exception: it prompts per app. - A compromised homeserver. ### 6. Idle lock (optional, separate issue) On Windows, `UserConsentVerifier` (Windows Hello / PIN) via the `windows` crate is feasible. After N minutes idle, blur the UI behind a lock screen and require the OS prompt. It's a UI lock only: the keys stay in memory. It helps on walk-away / shared machines. macOS would use LocalAuthentication; Linux has no standard, so it'd need a user passphrase (the Stronghold route) or no lock. ### Proposed implementation issues (once approved) 1. Desktop session preload + keychain backing store (`keyring`), with fallback and one release of dual-write. 2. `storageKey` for new logins, from the keychain. 3. Settings → Privacy status line ("stored in: system keychain / app data"). 4. Optional: Windows Hello idle lock.
Author
Owner

Step 1 (mirror) in review

  • cinny #254: merged and live. The desktop app keeps a verified copy of the login tokens in the OS keychain. The session is still read from localStorage, so nothing changes for login. Settings → General shows the status.
    • Until cinny-desktop #28 ships, every desktop build reports "Not available on this system yet".
  • cinny-desktop #28: open. It adds the secure_session_* commands, with Windows Credential Manager only. Linux stays unsupported for now because of possible wallet-unlock prompts.
  • Remaining:
    • a Windows test of #28 (Credential Manager entry session.Lotus Chat appears on login and is removed on logout), then merge;
    • step 2, after it has run on real installs: read tokens from the keychain and drop them from localStorage;
    • step 3: storageKey for the crypto store on new logins.
## Step 1 (mirror) in review - **cinny #254: merged and live.** The desktop app keeps a verified copy of the login tokens in the OS keychain. The session is still read from localStorage, so nothing changes for login. Settings → General shows the status. - Until cinny-desktop #28 ships, every desktop build reports *"Not available on this system yet"*. - **cinny-desktop #28: open.** It adds the `secure_session_*` commands, with Windows Credential Manager only. Linux stays unsupported for now because of possible wallet-unlock prompts. - **Remaining:** - a Windows test of #28 (Credential Manager entry `session.Lotus Chat` appears on login and is removed on logout), then merge; - **step 2**, after it has run on real installs: read tokens from the keychain and drop them from localStorage; - **step 3:** `storageKey` for the crypto store on new logins.
Author
Owner

Step 1 shipped. cinny #254 and cinny-desktop #28 are merged. The desktop app keeps a verified copy of the login tokens in Windows Credential Manager. The session is still read from localStorage.

Verified on the published nightly 4.12.371 on Windows: the Credential Manager round trip passes (supported, round trip, restored).

Next: step 2. Once step 1 has run on real installs for a while, read the tokens from the keychain and drop them from localStorage. That's the step that actually takes them off disk. Leaving this open for it.

**Step 1 shipped.** cinny #254 and cinny-desktop #28 are merged. The desktop app keeps a verified copy of the login tokens in Windows Credential Manager. The session is still read from localStorage. Verified on the published nightly 4.12.371 on Windows: the Credential Manager round trip passes (supported, round trip, restored). **Next: step 2.** Once step 1 has run on real installs for a while, read the tokens from the keychain and drop them from localStorage. That's the step that actually takes them off disk. Leaving this open for it.
jared closed this issue 2026-10-23 20:59:26 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#105