diff --git a/.changeset/feat-encrypted-push.md b/.changeset/feat-encrypted-push.md new file mode 100644 index 00000000..4bbbf8f4 --- /dev/null +++ b/.changeset/feat-encrypted-push.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Push notifications now use `event_id_only` format — Sygnal never sees message content or sender metadata, and encrypted messages are decrypted client-side when the app tab is open diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index eb1f775e..46c0ebb0 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -62,8 +62,7 @@ export async function enablePushNotifications( lang: navigator.language || 'en', data: { url: clientConfig.pushNotificationDetails?.pushNotifyUrl, - // format: 'event_id_only' as const, - events_only: true, + format: 'event_id_only' as const, endpoint: pushSubAtom.endpoint, p256dh: keys.p256dh, auth: keys.auth, @@ -111,7 +110,7 @@ export async function enablePushNotifications( lang: navigator.language || 'en', data: { url: clientConfig.pushNotificationDetails?.pushNotifyUrl, - // format: 'event_id_only' as const, + format: 'event_id_only' as const, endpoint: newSubscription.endpoint, p256dh: keys.p256dh, auth: keys.auth, diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5ec5d880..541545da 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -2,6 +2,7 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { ReactNode, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { + MatrixEvent, MatrixEventEvent, PushProcessor, RoomEvent, @@ -44,6 +45,7 @@ import { resolveNotificationPreviewText, } from '$utils/notificationStyle'; import { mobileOrTablet } from '$utils/user-agent'; +import { createDebugLogger } from '$utils/debugLogger'; import { useSlidingSyncActiveRoom } from '$hooks/useSlidingSyncActiveRoom'; import { getSlidingSyncManager } from '$client/initMatrix'; import { NotificationBanner } from '$components/notification-banner'; @@ -51,6 +53,8 @@ import { useCallSignaling } from '$hooks/useCallSignaling'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; +const pushRelayLog = createDebugLogger('push-relay'); + function clearMediaSessionQuickly(): void { if (!('mediaSession' in navigator)) return; // iOS registers the lock screen media player as a side-effect of @@ -619,6 +623,80 @@ function SlidingSyncActiveRoomSubscriber() { return null; } +/** + * Listens for decryptPushEvent messages from the service worker, decrypts the + * event using the local Olm/Megolm session, then replies with pushDecryptResult + * so the SW can show a notification with the real message content. + * Falls back gracefully (success: false) on any error or if keys are missing. + */ +function HandleDecryptPushEvent() { + const mx = useMatrixClient(); + + useEffect(() => { + if (!('serviceWorker' in navigator)) return undefined; + + const handleMessage = async (ev: MessageEvent) => { + const { data } = ev; + if (!data || data.type !== 'decryptPushEvent') return; + + const { rawEvent } = data as { rawEvent: Record }; + const eventId = rawEvent.event_id as string; + const roomId = rawEvent.room_id as string; + const decryptStart = performance.now(); + + try { + const mxEvent = new MatrixEvent(rawEvent as any); + await mx.decryptEventIfNeeded(mxEvent); + + const room = mx.getRoom(roomId); + const sender = mxEvent.getSender(); + let senderName = 'Someone'; + if (sender) { + senderName = getMxIdLocalPart(sender) ?? sender; + if (room) senderName = getMemberDisplayName(room, sender) ?? senderName; + } + + const decryptMs = Math.round(performance.now() - decryptStart); + const visible = document.visibilityState === 'visible'; + pushRelayLog.info('notification', 'Push relay decryption succeeded', { + eventType: mxEvent.getType(), + decryptMs, + appVisible: visible, + }); + + navigator.serviceWorker.controller?.postMessage({ + type: 'pushDecryptResult', + eventId, + success: true, + eventType: mxEvent.getType(), + content: mxEvent.getContent(), + sender_display_name: senderName, + room_name: room?.name ?? '', + visibilityState: document.visibilityState, + }); + } catch (err) { + console.warn('[app] HandleDecryptPushEvent: failed to decrypt push event', err); + pushRelayLog.error( + 'notification', + 'Push relay decryption failed', + err instanceof Error ? err : new Error(String(err)) + ); + navigator.serviceWorker.controller?.postMessage({ + type: 'pushDecryptResult', + eventId, + success: false, + visibilityState: document.visibilityState, + }); + } + }; + + navigator.serviceWorker.addEventListener('message', handleMessage); + return () => navigator.serviceWorker.removeEventListener('message', handleMessage); + }, [mx]); + + return null; +} + function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -646,6 +724,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/index.tsx b/src/index.tsx index 3248458b..7720db1e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -80,7 +80,7 @@ if ('serviceWorker' in navigator) { const activeId = getLocalStorageItem(ACTIVE_SESSION_KEY, undefined); const active = sessions.find((s) => s.userId === activeId) ?? sessions[0] ?? getFallbackSession(); - pushSessionToSW(active?.baseUrl, active?.accessToken); + pushSessionToSW(active?.baseUrl, active?.accessToken, active?.userId); }; navigator.serviceWorker diff --git a/src/sw-session.ts b/src/sw-session.ts index 4b2ec055..e4d2672d 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,4 +1,4 @@ -export function pushSessionToSW(baseUrl?: string, accessToken?: string) { +export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) { if (!('serviceWorker' in navigator)) return; if (!navigator.serviceWorker.controller) return; @@ -6,5 +6,6 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string) { type: 'setSession', accessToken, baseUrl, + userId, }); } diff --git a/src/sw.ts b/src/sw.ts index 4486f418..b374d6ff 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -24,6 +24,10 @@ const { handlePushNotificationPushData } = createPushNotifications(self, () => ( const SW_SETTINGS_CACHE = 'sable-sw-settings-v1'; const SW_SETTINGS_URL = '/sw-settings-meta'; +/** Cache key used to persist the active session so push-event fetches work after SW restart. */ +const SW_SESSION_CACHE = 'sable-sw-session-v1'; +const SW_SESSION_URL = '/sw-session-meta'; + async function persistSettings() { try { const cache = await self.caches.open(SW_SETTINGS_CACHE); @@ -62,9 +66,51 @@ async function loadPersistedSettings() { } } +async function persistSession(session: SessionInfo): Promise { + try { + const cache = await self.caches.open(SW_SESSION_CACHE); + await cache.put( + SW_SESSION_URL, + new Response(JSON.stringify(session), { headers: { 'Content-Type': 'application/json' } }) + ); + } catch { + // Ignore — caches may be unavailable in some environments. + } +} + +async function clearPersistedSession(): Promise { + try { + const cache = await self.caches.open(SW_SESSION_CACHE); + await cache.delete(SW_SESSION_URL); + } catch { + // Ignore. + } +} + +async function loadPersistedSession(): Promise { + try { + const cache = await self.caches.open(SW_SESSION_CACHE); + const response = await cache.match(SW_SESSION_URL); + if (!response) return undefined; + const s = await response.json(); + if (typeof s.accessToken === 'string' && typeof s.baseUrl === 'string') { + return { + accessToken: s.accessToken, + baseUrl: s.baseUrl, + userId: typeof s.userId === 'string' ? s.userId : undefined, + }; + } + return undefined; + } catch { + return undefined; + } +} + type SessionInfo = { accessToken: string; baseUrl: string; + /** Matrix user ID of the account, used to identify which account a push belongs to. */ + userId?: string; }; /** @@ -88,14 +134,22 @@ async function cleanupDeadClients() { }); } -function setSession(clientId: string, accessToken: unknown, baseUrl: unknown) { +function setSession(clientId: string, accessToken: unknown, baseUrl: unknown, userId?: unknown) { if (typeof accessToken === 'string' && typeof baseUrl === 'string') { - sessions.set(clientId, { accessToken, baseUrl }); + const info: SessionInfo = { + accessToken, + baseUrl, + userId: typeof userId === 'string' ? userId : undefined, + }; + sessions.set(clientId, info); console.debug('[SW] setSession: stored', clientId, baseUrl); + // Persist so push-event fetches work after iOS restarts the SW. + persistSession(info).catch(() => undefined); } else { // Logout or invalid session sessions.delete(clientId); console.debug('[SW] setSession: removed', clientId); + clearPersistedSession().catch(() => undefined); } const resolveSession = clientToResolve.get(clientId); @@ -143,6 +197,260 @@ async function requestSessionWithTimeout( return Promise.race([sessionPromise, timeout]); } +// --------------------------------------------------------------------------- +// Encrypted push — decryption relay +// --------------------------------------------------------------------------- + +/** + * The shape returned by the client tab after decrypting an encrypted push event. + * Also used as a partial pushData object for handlePushNotificationPushData. + */ +type DecryptionResult = { + eventId: string; + success: boolean; + eventType?: string; + content?: unknown; + sender_display_name?: string; + room_name?: string; + /** document.visibilityState reported by the responding app tab. */ + visibilityState?: string; +}; + +/** Pending decryption requests keyed by event_id. */ +const decryptionPendingMap = new Map void>(); + +/** + * Fetch a single raw Matrix event from the homeserver. + * Returns undefined on error (e.g. network failure, auth error, redacted event). + */ +async function fetchRawEvent( + baseUrl: string, + accessToken: string, + roomId: string, + eventId: string +): Promise | undefined> { + try { + const url = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/event/${encodeURIComponent(eventId)}`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) { + console.warn('[SW fetchRawEvent] HTTP', res.status, 'for', eventId); + return undefined; + } + return (await res.json()) as Record; + } catch (err) { + console.warn('[SW fetchRawEvent] error', err); + return undefined; + } +} + +/** + * Fetch the m.room.name state event from the homeserver. + * Returns undefined when not set (DMs and many encrypted rooms have no explicit name). + */ +async function fetchRoomName( + baseUrl: string, + accessToken: string, + roomId: string +): Promise { + try { + const url = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.name`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }); + if (!res.ok) return undefined; + const data = (await res.json()) as Record; + const { name } = data; + return typeof name === 'string' && name.trim() ? name.trim() : undefined; + } catch { + return undefined; + } +} + +/** + * Fetch a room member's displayname from homeserver member state. + * Returns undefined if the member has no displayname or the request fails. + */ +async function fetchMemberDisplayName( + baseUrl: string, + accessToken: string, + roomId: string, + userId: string +): Promise { + try { + const url = `${baseUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.member/${encodeURIComponent(userId)}`; + const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } }); + if (!res.ok) return undefined; + const data = (await res.json()) as Record; + const name = data.displayname; + return typeof name === 'string' && name.trim() ? name.trim() : undefined; + } catch { + return undefined; + } +} + +/** + * Return the first any-session we have stored (used for push fetches where we + * don't have a client ID, e.g. when the app is backgrounded but still loaded). + */ +function getAnyStoredSession(): SessionInfo | undefined { + return sessions.values().next().value; +} + +/** + * Extract the MXID localpart (@user:server → user) for fallback display names. + */ +function mxidLocalpart(userId: string): string { + return userId.match(/^@([^:]+):/)?.[1] ?? userId; +} + +/** + * Post a decryptPushEvent request to one of the open window clients and wait + * up to 5 s for the pushDecryptResult reply. + */ +async function requestDecryptionFromClient( + windowClients: readonly Client[], + rawEvent: Record +): Promise { + const eventId = rawEvent.event_id as string; + + // Chain clients sequentially using reduce to avoid await-in-loop and for-of. + return Array.from(windowClients).reduce( + async (prevPromise, client) => { + const prev = await prevPromise; + if (prev?.success) return prev; + + const promise = new Promise((resolve) => { + decryptionPendingMap.set(eventId, resolve); + }); + + const timeout = new Promise((resolve) => { + setTimeout(() => { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] timed out waiting for client', client.id); + resolve(undefined); + }, 5000); + }); + + try { + (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); + } catch (err) { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] postMessage error', err); + return undefined; + } + + return Promise.race([promise, timeout]); + }, + Promise.resolve(undefined) as Promise + ); +} + +/** + * Handle a minimal push payload (event_id_only format). + * Fetches the event from the homeserver and shows a notification. + * For encrypted events, attempts to relay decryption to an open app tab. + */ +async function handleMinimalPushPayload( + roomId: string, + eventId: string, + windowClients: readonly Client[] +): Promise { + // On iOS the SW is killed and restarted for every push, clearing the in-memory sessions + // Map. Fall back to the Cache Storage copy that was written when the user last opened + // the app (same pattern as settings persistence). + const session = getAnyStoredSession() ?? (await loadPersistedSession()); + + if (!session) { + // No session anywhere — app was never opened since install, or the user logged out. + // Show a minimal actionable notification so the user can tap through to the room. + console.debug('[SW push] minimal payload: no session, showing generic notification'); + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/apple/apple-touch-icon-180x180.png', + badge: '/public/res/apple/apple-touch-icon-72x72.png', + tag: `room-${roomId}`, + renotify: true, + data: { room_id: roomId, event_id: eventId }, + } as NotificationOptions); + return; + } + + // Fetch the raw event and room name state in parallel — both need only roomId. + const [rawEvent, roomNameFromState] = await Promise.all([ + fetchRawEvent(session.baseUrl, session.accessToken, roomId, eventId), + fetchRoomName(session.baseUrl, session.accessToken, roomId), + ]); + + if (!rawEvent) { + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/apple/apple-touch-icon-180x180.png', + badge: '/public/res/apple/apple-touch-icon-72x72.png', + tag: `room-${roomId}`, + renotify: true, + data: { room_id: roomId, event_id: eventId, user_id: session.userId }, + } as NotificationOptions); + return; + } + + const eventType = rawEvent.type as string | undefined; + const sender = rawEvent.sender as string | undefined; + // Fetch sender's display name from room member state; fall back to MXID localpart. + const senderDisplay = + (sender + ? await fetchMemberDisplayName(session.baseUrl, session.accessToken, roomId, sender) + : undefined) ?? (sender ? mxidLocalpart(sender) : 'Someone'); + // For DMs (no m.room.name state), use the sender's display name as the room name. + const resolvedRoomName = roomNameFromState ?? senderDisplay; + const baseData = { + room_id: roomId, + event_id: eventId, + user_id: session.userId, + }; + + if (eventType === 'm.room.encrypted') { + // Try to relay decryption to an open app tab. + const result = + windowClients.length > 0 + ? await requestDecryptionFromClient(windowClients, rawEvent) + : undefined; + + // If the relay responded and the app is currently visible, the in-app UI is already + // displaying the message — skip the OS notification entirely. + if (result?.visibilityState === 'visible') return; + + if (result?.success) { + // App was backgrounded but not frozen — decryption succeeded. + await handlePushNotificationPushData({ + ...baseData, + type: result.eventType, + content: result.content, + sender_display_name: result.sender_display_name ?? senderDisplay, + // Prefer relay's room name (has m.direct / computed SDK name); fall back to state fetch. + room_name: result.room_name || resolvedRoomName, + }); + } else { + // App is frozen or fully closed — show "Encrypted message" fallback. + await handlePushNotificationPushData({ + ...baseData, + type: 'm.room.encrypted', + content: {}, + sender_display_name: senderDisplay, + room_name: resolvedRoomName, + }); + } + } else { + // Unencrypted event — we have the plaintext, show it. + await handlePushNotificationPushData({ + ...baseData, + type: eventType, + content: rawEvent.content, + sender_display_name: senderDisplay, + room_name: resolvedRoomName, + }); + } +} + self.addEventListener('install', (event: ExtendableEvent) => { event.waitUntil(self.skipWaiting()); }); @@ -165,12 +473,23 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const { data } = event; if (!data || typeof data !== 'object') return; - const { type, accessToken, baseUrl } = data as Record; + const { type, accessToken, baseUrl, userId } = data as Record; if (type === 'setSession') { - setSession(client.id, accessToken, baseUrl); + setSession(client.id, accessToken, baseUrl, userId); event.waitUntil(cleanupDeadClients()); } + if (type === 'pushDecryptResult') { + // Resolve a pending decryption request from handleMinimalPushPayload + const { eventId } = data as { eventId?: string }; + if (typeof eventId === 'string') { + const resolve = decryptionPendingMap.get(eventId); + if (resolve) { + decryptionPendingMap.delete(eventId); + resolve(data as DecryptionResult); + } + } + } if (type === 'setAppVisible') { if (typeof (data as { visible?: unknown }).visible === 'boolean') { appIsVisible = (data as { visible: boolean }).visible; @@ -292,14 +611,23 @@ self.addEventListener('fetch', (event: FetchEvent) => { ); }); +// Detect a minimal (event_id_only) payload: has room_id + event_id but no +// event type field — meaning the homeserver stripped the event content. +function isMinimalPushPayload(data: unknown): data is { room_id: string; event_id: string } { + if (!data || typeof data !== 'object') return false; + const d = data as Record; + return typeof d.room_id === 'string' && typeof d.event_id === 'string' && !d.type; +} + const onPushNotification = async (event: PushEvent) => { if (!event?.data) return; // The SW may have been restarted by the OS (iOS is aggressive about this), // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. - const [, clients] = await Promise.all([ + const [, , clients] = await Promise.all([ loadPersistedSettings(), + loadPersistedSession(), self.clients.matchAll({ type: 'window', includeUncontrolled: true }), ]); @@ -346,9 +674,21 @@ const onPushNotification = async (event: PushEvent) => { // Badging API absent (Firefox/Gecko) — continue to show the notification. } + // event_id_only format: fetch the event ourselves and (for E2EE rooms) try + // to relay decryption to an open app tab. + if (isMinimalPushPayload(pushData)) { + console.debug('[SW push] minimal payload detected — fetching event', pushData.event_id); + await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + return; + } + await handlePushNotificationPushData(pushData); }; +// --------------------------------------------------------------------------- +// Push handler +// --------------------------------------------------------------------------- + self.addEventListener('push', (event: PushEvent) => event.waitUntil(onPushNotification(event))); self.addEventListener('notificationclick', (event: NotificationEvent) => {