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<userId>       - 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 16:56:08 -04:00
co-authored by Claude Opus 4.8
parent 7c28ba58b2
commit 726cefb5ab
9 changed files with 187 additions and 7 deletions
+5 -4
View File
@@ -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.
+70
View File
@@ -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<string, string>();
(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 modules concern');
assert.ok(store.has('draft-msg-!room:server'));
});
+43
View File
@@ -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<userId>` — 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);
};
+13
View File
@@ -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 */
}
};
+13
View File
@@ -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 */
}
};
+12
View File
@@ -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 */
}
};
+13
View File
@@ -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 */
}
};
+13
View File
@@ -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 */
}
};
+5 -3
View File
@@ -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();