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:
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user