Files
cinny/src/app/state/plaintextCaches.test.ts
T
jaredandClaude Opus 4.8 726cefb5ab 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>
2026-07-18 16:56:08 -04:00

71 lines
2.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'));
});