fix(security): bookmarks/reminders stop storing decrypted text server-side

For encrypted rooms, bookmarks persist only {roomId, eventId, savedAt}
and reminders only their non-text fields; the preview, room name and
sender resolve locally at render/fire time from the timeline (with a
"Message unavailable" fallback). A one-time, loop-guarded cleanup strips
text from existing entries in currently-encrypted rooms. Unencrypted
rooms are unchanged. Unit-tested.

Fixes #10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 19:46:05 -04:00
co-authored by Claude Opus 5
parent 23ee156f2f
commit 9bdf4ff1fd
5 changed files with 272 additions and 21 deletions
+69 -3
View File
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore';
@@ -6,7 +7,10 @@ export type Reminder = {
roomId: string;
eventId: string;
timestamp: number;
message: string;
// Message preview. Account data is stored UNENCRYPTED on the homeserver, so
// this is omitted for E2EE rooms (see stripReminderText) and the toast resolves
// the text locally at fire time. Optional for entries stored before that policy.
message?: string;
};
const REMINDERS_KEY = 'io.lotus.reminders';
@@ -15,6 +19,43 @@ type RemindersContent = {
reminders: Reminder[];
};
/** Reduce a reminder to its non-text fields ({roomId, eventId, timestamp}). */
export const stripReminderText = (r: Reminder): Reminder => ({
roomId: r.roomId,
eventId: r.eventId,
timestamp: r.timestamp,
});
/** Apply the E2EE storage policy to a reminder about to be persisted. */
export const toStorableReminder = (
r: Reminder,
isEncryptedRoom: (roomId: string) => boolean,
): Reminder => (isEncryptedRoom(r.roomId) ? stripReminderText(r) : r);
/**
* One-time cleanup for entries persisted before the E2EE policy; `undefined`
* when nothing needs to change (so callers can skip the write).
*/
export const cleanupEncryptedReminders = (
reminders: Reminder[],
isEncryptedRoom: (roomId: string) => boolean,
): Reminder[] | undefined => {
let changed = false;
const next = reminders.map((r) => {
if (r.message !== undefined && isEncryptedRoom(r.roomId)) {
changed = true;
return stripReminderText(r);
}
return r;
});
return changed ? next : undefined;
};
const isEncryptedRoomFor =
(mx: MatrixClient) =>
(roomId: string): boolean =>
mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false;
// Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped (writes are serialized to avoid
// lost updates, since setAccountData replaces the whole content with no merge).
@@ -24,6 +65,9 @@ const remindersStore = createAccountDataListStore<Reminder[], RemindersContent>(
write: (reminders) => ({ reminders }),
});
// Once-per-client guard for the load-time cleanup (mirrors useBookmarks).
let cleanedUpFor: MatrixClient | null = null;
export function useReminders(): {
reminders: Reminder[];
addReminder: (r: Reminder) => Promise<void>;
@@ -33,8 +77,30 @@ export function useReminders(): {
const mx = useMatrixClient();
const reminders = remindersStore.useValue(mx);
// Strip text from entries saved (by older builds) for rooms that are encrypted,
// writing the store back at most once per client.
useEffect(() => {
if (cleanedUpFor === mx) return;
cleanedUpFor = mx;
if (cleanupEncryptedReminders(remindersStore.getLatest(mx), isEncryptedRoomFor(mx))) {
remindersStore
.enqueueWrite(
mx,
(current) => cleanupEncryptedReminders(current, isEncryptedRoomFor(mx)) ?? current,
)
.catch(() => {
if (cleanedUpFor === mx) cleanedUpFor = null;
});
}
}, [mx]);
const addReminder = useCallback(
(r: Reminder) => remindersStore.enqueueWrite(mx, (current) => [...current, r]),
(r: Reminder) =>
remindersStore.enqueueWrite(mx, (current) => [
...current,
// Never upload plaintext for E2EE rooms — account data is server-readable.
toStorableReminder(r, isEncryptedRoomFor(mx)),
]),
[mx],
);