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:
@@ -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.
|
||||
|
||||
@@ -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 module’s concern');
|
||||
assert.ok(store.has('draft-msg-!room:server'));
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 */
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user