From d20c7e210b6ed82474827c0dc9e2ad8c88e2b7ae Mon Sep 17 00:00:00 2001 From: Dennis Landmann Date: Sun, 19 Apr 2026 21:07:47 +0200 Subject: [PATCH] fix(senderkey): backfill missing key bundles on mount + refresh on incoming bundle --- apps/desktop/src-tauri/tauri.conf.json | 2 +- apps/desktop/src/lib/conversationKeySync.ts | 244 +++++++++++++----- .../src/lib/useConversationMessages.ts | 16 ++ 3 files changed, 191 insertions(+), 71 deletions(-) diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 6be84c0..5acd4e0 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ChatApp", - "version": "0.3.7", + "version": "0.3.8", "identifier": "com.meinname.chatapp", "build": { "beforeDevCommand": "pnpm vite:dev", diff --git a/apps/desktop/src/lib/conversationKeySync.ts b/apps/desktop/src/lib/conversationKeySync.ts index 5e66762..013768d 100644 --- a/apps/desktop/src/lib/conversationKeySync.ts +++ b/apps/desktop/src/lib/conversationKeySync.ts @@ -5,16 +5,24 @@ import { pgHexToBytes } from '@chat-app/shared/supabase'; import { devLocalSecretStore } from './secretStore'; import { supabase } from './supabase'; -// Watches the `devices` table for INSERTs and, whenever a peer registers a -// new device that's in any of our conversations, wraps the active -// conversation key for the freshly-arrived device. This makes Sender-Key -// onboarding "just work" — the new device picks up the bundle from -// `conversation_keys` and can decrypt the entire history once at least one -// of our existing devices was online to do the wrapping. +// Watches the `devices` table for new entries AND, on mount, scans every +// conversation we participate in for missing key bundles. Fills gaps by +// re-wrapping our active conv-key for the missing recipient devices. // -// At-least-once delivery: if no existing device of any participant is online -// at the moment the new device joins, the new device stays unable to decrypt -// until SOMEONE comes online and runs this loop. Standard Signal trade-off. +// This fixes the "cannot decrypt" cliff for devices that registered while +// no other participant device was online to share the key with them. + +interface SyncCtx { + myUserId: string; + myDeviceId: string; + priv: Uint8Array; +} + +// db-types is stale for `conversation_keys`/`active_key_version`; bypass. +function rawFrom(table: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (supabase as unknown as { from: (t: string) => any }).from(table); +} export function startConversationKeySync( ownUserId: string, @@ -23,8 +31,13 @@ export function startConversationKeySync( let cancelled = false; let priv: Uint8Array | null = null; - void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then((pk) => { + void loadDevicePrivateKey(devLocalSecretStore, ownUserId, ownDeviceId).then(async (pk) => { + if (cancelled) return; priv = pk; + if (!priv) return; + // Run a full backfill once we have the private key — covers devices that + // registered while we were offline. + await syncAllExistingGaps({ myUserId: ownUserId, myDeviceId: ownDeviceId, priv }); }); const channel = supabase @@ -36,73 +49,164 @@ export function startConversationKeySync( if (cancelled) return; const row = payload.new; if (!row?.id || !row.user_id || !row.public_key) return; - // Skip our own devices — we don't need to send keys to ourselves - // (each install bootstraps its own keys via getOrCreateConvKey). if (row.user_id === ownUserId && row.id === ownDeviceId) return; - void wrapKeysForNewDevice(ownUserId, ownDeviceId, row.id, row.user_id, row.public_key); + if (!priv) return; // backfill on mount will catch it later + void wrapForOneDevice( + { myUserId: ownUserId, myDeviceId: ownDeviceId, priv }, + row.id, + row.user_id, + row.public_key, + ); }, ) .subscribe(); - async function wrapKeysForNewDevice( - myUserId: string, - myDeviceId: string, - newDeviceId: string, - newDeviceUserId: string, - newDevicePubKeyHex: string, - ) { - if (!priv) { - priv = await loadDevicePrivateKey(devLocalSecretStore, myUserId, myDeviceId); - if (!priv) return; - } - const newPub = pgHexToBytes(newDevicePubKeyHex); - const ownCtx: OwnDeviceCtx = { - userId: myUserId, - deviceId: myDeviceId, - privateKey: priv, - }; - - // Find conversations I'm in that the new device's user is also in. - const { data: shared, error: sErr } = await supabase - .from('conversation_members') - .select('conversation_id') - .eq('user_id', newDeviceUserId); - if (sErr) { - console.warn('keySync member lookup failed', sErr); - return; - } - const peerConvs = new Set((shared ?? []).map((r) => r.conversation_id as string)); - if (peerConvs.size === 0) return; - - const { data: mine, error: mErr } = await supabase - .from('conversation_members') - .select('conversation_id') - .eq('user_id', myUserId) - .eq('accepted', true); - if (mErr) { - console.warn('keySync own-member lookup failed', mErr); - return; - } - - const targets: string[] = []; - for (const row of mine ?? []) { - const id = row.conversation_id as string; - if (peerConvs.has(id)) targets.push(id); - } - - for (const convId of targets) { - try { - await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx); - } catch (err: unknown) { - // Common: this device has no key for that conv yet (was offline at - // bootstrap). Other online devices will handle it. - console.warn('shareConvKeyToDevice failed', { convId, err }); - } - } - } - return () => { cancelled = true; void supabase.removeChannel(channel); }; } + +async function listMyConversationIds(myUserId: string): Promise { + const { data, error } = await supabase + .from('conversation_members') + .select('conversation_id') + .eq('user_id', myUserId) + .eq('accepted', true); + if (error) { + console.warn('keySync: own-member lookup failed', error); + return []; + } + return (data ?? []).map((r) => r.conversation_id as string); +} + +async function listConversationDevices( + conversationId: string, +): Promise<{ id: string; user_id: string; public_key: string }[]> { + const { data: members, error: mErr } = await supabase + .from('conversation_members') + .select('user_id') + .eq('conversation_id', conversationId) + .eq('accepted', true); + if (mErr) { + console.warn('keySync: members lookup failed', mErr); + return []; + } + const userIds = (members ?? []).map((m) => m.user_id as string); + if (userIds.length === 0) return []; + + const { data: devices, error: dErr } = await supabase + .from('devices') + .select('id, user_id, public_key') + .in('user_id', userIds); + if (dErr) { + console.warn('keySync: devices lookup failed', dErr); + return []; + } + return (devices ?? []) as { id: string; user_id: string; public_key: string }[]; +} + +async function listExistingKeyRecipients( + conversationId: string, + keyVersion: number, +): Promise> { + const { data, error } = await rawFrom('conversation_keys') + .select('recipient_device_id') + .eq('conversation_id', conversationId) + .eq('key_version', keyVersion); + if (error) { + console.warn('keySync: existing keys lookup failed', error); + return new Set(); + } + return new Set((data ?? []).map((r: { recipient_device_id: string }) => r.recipient_device_id)); +} + +async function getActiveKeyVersion(conversationId: string): Promise { + const { data, error } = await rawFrom('conversations') + .select('active_key_version') + .eq('id', conversationId) + .single(); + if (error) { + console.warn('keySync: active key version lookup failed', error); + return 1; + } + return (data as { active_key_version: number }).active_key_version; +} + +async function syncAllExistingGaps(ctx: SyncCtx): Promise { + const convs = await listMyConversationIds(ctx.myUserId); + for (const convId of convs) { + try { + await syncOneConversationGaps(ctx, convId); + } catch (err: unknown) { + console.warn('keySync: conv gap sync failed', { convId, err }); + } + } +} + +async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise { + const version = await getActiveKeyVersion(convId); + const devices = await listConversationDevices(convId); + if (devices.length === 0) return; + + const recipients = await listExistingKeyRecipients(convId, version); + const ownCtx: OwnDeviceCtx = { + userId: ctx.myUserId, + deviceId: ctx.myDeviceId, + privateKey: ctx.priv, + }; + + for (const dev of devices) { + if (recipients.has(dev.id)) continue; + // Skip our own device — we already have the bundle if we're capable of + // sharing (or don't need it if we ourselves haven't been wrapped yet). + if (dev.id === ctx.myDeviceId) continue; + try { + await shareConvKeyToDevice(supabase, convId, dev.id, pgHexToBytes(dev.public_key), ownCtx); + } catch (err: unknown) { + // Most common: this device hasn't been wrapped for us yet either, so + // tryGetConvKey couldn't unwrap. Another peer with the key will fill + // the gap when they hit syncAllExistingGaps. + console.warn('keySync: shareConvKeyToDevice gap-fill failed', { + convId, + recipient: dev.id, + err, + }); + } + } +} + +async function wrapForOneDevice( + ctx: SyncCtx, + newDeviceId: string, + newDeviceUserId: string, + newDevicePubHex: string, +): Promise { + const myConvs = new Set(await listMyConversationIds(ctx.myUserId)); + const { data: peerMember, error: pErr } = await supabase + .from('conversation_members') + .select('conversation_id') + .eq('user_id', newDeviceUserId); + if (pErr) { + console.warn('keySync: peer-member lookup failed', pErr); + return; + } + const sharedConvs = (peerMember ?? []) + .map((r) => r.conversation_id as string) + .filter((id) => myConvs.has(id)); + if (sharedConvs.length === 0) return; + + const newPub = pgHexToBytes(newDevicePubHex); + const ownCtx: OwnDeviceCtx = { + userId: ctx.myUserId, + deviceId: ctx.myDeviceId, + privateKey: ctx.priv, + }; + for (const convId of sharedConvs) { + try { + await shareConvKeyToDevice(supabase, convId, newDeviceId, newPub, ownCtx); + } catch (err: unknown) { + console.warn('keySync: shareConvKeyToDevice failed', { convId, err }); + } + } +} diff --git a/apps/desktop/src/lib/useConversationMessages.ts b/apps/desktop/src/lib/useConversationMessages.ts index 1179d54..de8ae6a 100644 --- a/apps/desktop/src/lib/useConversationMessages.ts +++ b/apps/desktop/src/lib/useConversationMessages.ts @@ -238,6 +238,22 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar } }, ) + // When a peer device wraps the conversation-key for us (e.g. we just + // registered a fresh device), re-decrypt the visible messages. + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + table: 'conversation_keys', + filter: 'conversation_id=eq.' + conversationId, + }, + (payload: { new: { recipient_device_id?: string } }) => { + if (payload.new?.recipient_device_id === deviceId) { + void refresh(); + } + }, + ) .subscribe(); return () => {