From 726cefb5ab3da40bff04d220a46139176f41a6c0 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 18 Jul 2026 16:56:08 -0400 Subject: [PATCH] fix(privacy): wipe plaintext/PII localStorage caches on logout (SEC-1/2) Several localStorage caches held decrypted message content or user PII and survived a normal logout, leaving residue on a shared device (the search index was already wiped; these were not): - cinny_scheduled_messages_v1 - decrypted IContent.body of pending sends - cinny_recent_searches_v1 - search query text - cinny_recent_forward_targets_v1 - recent forward contact/room graph - cinny_recent_gifs_v1 / cinny_recent_stickers_v1 - media the user sent - navToActivePath - per-space last-visited room paths - (plus the translation cache added earlier) Add a clear function per module and a single auditable clearPlaintextCaches() aggregator, called from both logout paths (logoutClient + the server-forced SessionLoggedOut handler) alongside the existing session/search-index wipes. Unit-tested. Deliberately NOT cleared (documented in the aggregator): unsent composer drafts and the presence status message (preserved by product decision N98); SDK sync/crypto store + io.lotus.* account data (reminders/bookmarks/notes), already wiped by mx.clearStores(); low-sensitivity UI/metadata residue. The forward-targets/gifs/stickers/nav-path additions and the accurate "not covered" documentation address findings from two review passes. Co-Authored-By: Claude Opus 4.8 --- src/app/pages/client/ClientRoot.tsx | 9 ++-- src/app/state/plaintextCaches.test.ts | 70 +++++++++++++++++++++++++++ src/app/state/plaintextCaches.ts | 43 ++++++++++++++++ src/app/state/recentForwardTargets.ts | 13 +++++ src/app/state/recentGifs.ts | 13 +++++ src/app/state/recentSearches.ts | 12 +++++ src/app/state/recentStickers.ts | 13 +++++ src/app/state/scheduledMessages.ts | 13 +++++ src/client/initMatrix.ts | 8 +-- 9 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 src/app/state/plaintextCaches.test.ts create mode 100644 src/app/state/plaintextCaches.ts diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index e4925bfcb..d42a17ce9 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -32,7 +32,7 @@ import { startClient, } from '../../../client/initMatrix'; import { deleteSearchCacheDatabase } from '../../utils/searchCache'; -import { clearTranslationCache } from '../../state/translation'; +import { clearPlaintextCaches } from '../../state/plaintextCaches'; import { SplashScreen } from '../../components/splash-screen'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { CapabilitiesProvider } from '../../hooks/useCapabilities'; @@ -163,9 +163,10 @@ const useLogoutListener = (mx?: MatrixClient) => { // change) — the manual logout path already does, but this path didn't, so // the plaintext survived on disk (and persist() makes it non-evictable). await deleteSearchCacheDatabase(); - // The message-translation cache also holds decrypted plaintext — wipe it - // on server-forced logout too. - clearTranslationCache(); + // Other localStorage caches also hold decrypted plaintext / PII + // (translation, scheduled messages, recent searches/forwards/gifs/ + // stickers, nav paths) — wipe them on server-forced logout too. + clearPlaintextCaches(mx?.getUserId() ?? undefined); // Remove only the session credential keys — NOT settings, drafts, and // other preferences (N98). The SDK's IndexedDB stores are cleared above; // window.localStorage.clear() is reserved for the explicit reset path. diff --git a/src/app/state/plaintextCaches.test.ts b/src/app/state/plaintextCaches.test.ts new file mode 100644 index 000000000..a4c688e1f --- /dev/null +++ b/src/app/state/plaintextCaches.test.ts @@ -0,0 +1,70 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// These modules touch localStorage at import/runtime. Provide a minimal mock +// that records removed keys, then import dynamically (a static import would +// hoist above the mock). +const removed: string[] = []; +const store = new Map(); +(globalThis as { localStorage?: unknown }).localStorage = { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => { + store.set(k, v); + }, + removeItem: (k: string) => { + removed.push(k); + store.delete(k); + }, +}; + +const { clearPlaintextCaches } = await import('./plaintextCaches'); + +test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => { + store.clear(); + store.set('cinny_translation_cache_v1', '[]'); + store.set('cinny_scheduled_messages_v1', '{}'); + store.set('cinny_recent_searches_v1', '[]'); + store.set('cinny_recent_forward_targets_v1', '[]'); + store.set('cinny_recent_gifs_v1', '[]'); + store.set('cinny_recent_stickers_v1', '[]'); + removed.length = 0; + + clearPlaintextCaches(); + + for (const key of [ + 'cinny_translation_cache_v1', + 'cinny_scheduled_messages_v1', + 'cinny_recent_searches_v1', + 'cinny_recent_forward_targets_v1', + 'cinny_recent_gifs_v1', + 'cinny_recent_stickers_v1', + ]) { + assert.ok(removed.includes(key), `${key} cleared`); + } + assert.equal(store.size, 0, 'all keys gone from store'); +}); + +test('clearPlaintextCaches clears the per-user nav-path store only when given a userId', () => { + store.clear(); + store.set('navToActivePath@me:server', '{}'); + removed.length = 0; + + clearPlaintextCaches(); // no userId -> nav path untouched + assert.ok(!removed.includes('navToActivePath@me:server'), 'nav path kept without userId'); + + clearPlaintextCaches('@me:server'); + assert.ok(removed.includes('navToActivePath@me:server'), 'nav path cleared with userId'); +}); + +test('clearPlaintextCaches does NOT touch drafts or session keys', () => { + store.clear(); + store.set('draft-msg-!room:server', '{"body":"unsent"}'); + store.set('cinny_session', '{"accessToken":"x"}'); + removed.length = 0; + + clearPlaintextCaches('@me:server'); + + assert.ok(!removed.includes('draft-msg-!room:server'), 'draft preserved (N98)'); + assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern'); + assert.ok(store.has('draft-msg-!room:server')); +}); diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts new file mode 100644 index 000000000..f771936c0 --- /dev/null +++ b/src/app/state/plaintextCaches.ts @@ -0,0 +1,43 @@ +import { clearTranslationCache } from './translation'; +import { clearScheduledMessages } from './scheduledMessages'; +import { clearRecentSearches } from './recentSearches'; +import { clearRecentForwardTargets } from './recentForwardTargets'; +import { clearRecentGifs } from './recentGifs'; +import { clearRecentStickers } from './recentStickers'; +import { clearNavToActivePathStore } from './navToActivePath'; + +/** + * Single auditable place that wipes the `localStorage` caches holding decrypted + * message content, sent media, or a user's messaging/nav activity. Called on + * logout so this residue can't survive on a shared device. + * + * Swept here: + * - `cinny_translation_cache_v1` — decrypted translated message text + * - `cinny_scheduled_messages_v1` — decrypted `IContent.body` of pending sends + * - `cinny_recent_searches_v1` — search query text (PII) + * - `cinny_recent_forward_targets_v1` — recent forward contact/room graph (PII) + * - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent + * - `navToActivePath` — per-space last-visited room paths (needs userId) + * + * NOT swept here (by design): + * - session credential keys → `removeFallbackSession()` + * - the SDK sync/crypto store + all `io.lotus.*` account data (reminders, + * bookmarks, user notes, status presets — themselves plaintext) → wiped by + * `mx.clearStores()` on both logout paths + * - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()` + * - unsent composer drafts (`draft-msg-*`) and the presence status message + * (`lotus-status-msg-*`) are deliberately preserved across a normal logout + * (N98); clearing them is a separate product decision + * - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed + * nav/space categories, `cinny_oidc_dynamic_clients`) is treated as + * preferences, not swept here + */ +export const clearPlaintextCaches = (userId?: string): void => { + clearTranslationCache(); + clearScheduledMessages(); + clearRecentSearches(); + clearRecentForwardTargets(); + clearRecentGifs(); + clearRecentStickers(); + if (userId) clearNavToActivePathStore(userId); +}; diff --git a/src/app/state/recentForwardTargets.ts b/src/app/state/recentForwardTargets.ts index cf75b6b17..aa6e6b45f 100644 --- a/src/app/state/recentForwardTargets.ts +++ b/src/app/state/recentForwardTargets.ts @@ -39,3 +39,16 @@ export const addRecentForwardTarget = (prev: string[], roomId: string): string[] const withoutDupe = prev.filter((id) => id !== roomId); return [roomId, ...withoutDupe].slice(0, MAX_RECENT_FORWARD_TARGETS); }; + +/** + * Wipe persisted recent forward targets. Called on logout — the list is the + * user's recent messaging contact/room graph (PII) and must not survive a + * session on a shared device. + */ +export const clearRecentForwardTargets = (): void => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* localStorage unavailable — nothing to clear */ + } +}; diff --git a/src/app/state/recentGifs.ts b/src/app/state/recentGifs.ts index 6927723c6..9179c5dff 100644 --- a/src/app/state/recentGifs.ts +++ b/src/app/state/recentGifs.ts @@ -48,3 +48,16 @@ export const addRecentGif = ( const withoutDupe = prev.filter((g) => g.url !== gif.url); return [gif, ...withoutDupe].slice(0, max); }; + +/** + * Wipe persisted recent GIFs. Called on logout — these are media the user sent + * (can be personally sensitive) and must not surface under "Recent" to the next + * person on a shared device. + */ +export const clearRecentGifs = (): void => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* localStorage unavailable — nothing to clear */ + } +}; diff --git a/src/app/state/recentSearches.ts b/src/app/state/recentSearches.ts index 5f29ee900..5f9ec57e6 100644 --- a/src/app/state/recentSearches.ts +++ b/src/app/state/recentSearches.ts @@ -36,3 +36,15 @@ export const addRecentSearch = (prev: string[], term: string): string[] => { const withoutDupe = prev.filter((t) => t !== trimmed); return [trimmed, ...withoutDupe].slice(0, MAX_RECENT_SEARCHES); }; + +/** + * Wipe persisted recent search terms. Called on logout — search queries are + * user PII and must not survive a session on a shared device. + */ +export const clearRecentSearches = (): void => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* localStorage unavailable — nothing to clear */ + } +}; diff --git a/src/app/state/recentStickers.ts b/src/app/state/recentStickers.ts index 15938a838..69e567472 100644 --- a/src/app/state/recentStickers.ts +++ b/src/app/state/recentStickers.ts @@ -48,3 +48,16 @@ export const addRecentSticker = ( const withoutDupe = prev.filter((s) => s.url !== sticker.url); return [sticker, ...withoutDupe].slice(0, max); }; + +/** + * Wipe persisted recent stickers. Called on logout — these are stickers the + * user sent (mxc + label text) and must not surface under "Recent" to the next + * person on a shared device. + */ +export const clearRecentStickers = (): void => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* localStorage unavailable — nothing to clear */ + } +}; diff --git a/src/app/state/scheduledMessages.ts b/src/app/state/scheduledMessages.ts index 0a06434f0..ddceb704b 100644 --- a/src/app/state/scheduledMessages.ts +++ b/src/app/state/scheduledMessages.ts @@ -40,3 +40,16 @@ export const scheduledMessagesAtom = atom( }); }, ); + +/** + * Wipe persisted scheduled messages. Called on logout — the stored content is + * decrypted message plaintext (the E2EE `body`), so it must not survive a + * session on a shared device. + */ +export const clearScheduledMessages = (): void => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + /* localStorage unavailable — nothing to clear */ + } +}; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 65c681c5f..94574039e 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -7,7 +7,7 @@ import { LotusOidcTokenRefresher } from './oidcTokenRefresher'; import { revokeOidcTokens } from './oidcLogout'; import { pushSessionToSW } from '../sw-session'; import { deleteSearchCacheDatabase } from '../app/utils/searchCache'; -import { clearTranslationCache } from '../app/state/translation'; +import { clearPlaintextCaches } from '../app/state/plaintextCaches'; // Thrown when the local IndexedDB has a higher schema version than this SDK expects. // This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted). @@ -124,8 +124,10 @@ export const logoutClient = async (mx: MatrixClient) => { // The opt-in local search index stores decrypted plaintext — always wipe it // on logout. (clearLoginData below nukes all IDB databases, covering it too.) await deleteSearchCacheDatabase(); - // The message-translation cache also holds decrypted plaintext — wipe it too. - clearTranslationCache(); + // Other localStorage caches also hold decrypted plaintext / PII (translation, + // scheduled messages, recent searches/forwards/gifs/stickers, nav paths) — + // wipe them too. + clearPlaintextCaches(mx.getUserId() ?? undefined); // Remove only the session credential keys, preserving user preferences and // unsent drafts (N98). The factory-reset path is clearLoginData() below. removeFallbackSession();