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,
|
startClient,
|
||||||
} from '../../../client/initMatrix';
|
} from '../../../client/initMatrix';
|
||||||
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
||||||
import { clearTranslationCache } from '../../state/translation';
|
import { clearPlaintextCaches } from '../../state/plaintextCaches';
|
||||||
import { SplashScreen } from '../../components/splash-screen';
|
import { SplashScreen } from '../../components/splash-screen';
|
||||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
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
|
// change) — the manual logout path already does, but this path didn't, so
|
||||||
// the plaintext survived on disk (and persist() makes it non-evictable).
|
// the plaintext survived on disk (and persist() makes it non-evictable).
|
||||||
await deleteSearchCacheDatabase();
|
await deleteSearchCacheDatabase();
|
||||||
// The message-translation cache also holds decrypted plaintext — wipe it
|
// Other localStorage caches also hold decrypted plaintext / PII
|
||||||
// on server-forced logout too.
|
// (translation, scheduled messages, recent searches/forwards/gifs/
|
||||||
clearTranslationCache();
|
// stickers, nav paths) — wipe them on server-forced logout too.
|
||||||
|
clearPlaintextCaches(mx?.getUserId() ?? undefined);
|
||||||
// Remove only the session credential keys — NOT settings, drafts, and
|
// Remove only the session credential keys — NOT settings, drafts, and
|
||||||
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
||||||
// window.localStorage.clear() is reserved for the explicit reset path.
|
// 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);
|
const withoutDupe = prev.filter((id) => id !== roomId);
|
||||||
return [roomId, ...withoutDupe].slice(0, MAX_RECENT_FORWARD_TARGETS);
|
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);
|
const withoutDupe = prev.filter((g) => g.url !== gif.url);
|
||||||
return [gif, ...withoutDupe].slice(0, max);
|
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);
|
const withoutDupe = prev.filter((t) => t !== trimmed);
|
||||||
return [trimmed, ...withoutDupe].slice(0, MAX_RECENT_SEARCHES);
|
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);
|
const withoutDupe = prev.filter((s) => s.url !== sticker.url);
|
||||||
return [sticker, ...withoutDupe].slice(0, max);
|
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 { revokeOidcTokens } from './oidcLogout';
|
||||||
import { pushSessionToSW } from '../sw-session';
|
import { pushSessionToSW } from '../sw-session';
|
||||||
import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
|
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.
|
// 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).
|
// 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
|
// The opt-in local search index stores decrypted plaintext — always wipe it
|
||||||
// on logout. (clearLoginData below nukes all IDB databases, covering it too.)
|
// on logout. (clearLoginData below nukes all IDB databases, covering it too.)
|
||||||
await deleteSearchCacheDatabase();
|
await deleteSearchCacheDatabase();
|
||||||
// The message-translation cache also holds decrypted plaintext — wipe it too.
|
// Other localStorage caches also hold decrypted plaintext / PII (translation,
|
||||||
clearTranslationCache();
|
// 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
|
// Remove only the session credential keys, preserving user preferences and
|
||||||
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||||
removeFallbackSession();
|
removeFallbackSession();
|
||||||
|
|||||||
Reference in New Issue
Block a user