import { type OwnUserCtx, shareConvKeyToUser } from '@chat-app/shared/chat'; import { pgHexToBytes } from '@chat-app/shared/supabase'; import { supabase } from './supabase'; // 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 users. // // This fixes the "cannot decrypt" cliff for users that joined while no // other participant was online to share the key with them. export 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); } // Module-level flag — gap-fill runs once per (user, device) combo per // process lifetime. Page reloads / route changes don't re-trigger it. const backfilledKey = new Set(); export function startConversationKeySync( ownUserId: string, ownDeviceId: string, ): () => void { // DISABLED: auto-share of conversation keys to newly-registered devices // is gone. Without it, account takeover (stolen password / new device // registered by attacker) no longer automatically grants history access // — an attacker would have a working device-key but no conv-key wraps. // // History access paths still supported: // 1. Backup-Restore — restores the OLD device-id + privkey, so the // server-side wraps for that device-id are accessible as before. // 2. (Planned) Approval flow — existing device or conversation peer // explicitly approves a new device, then conv-keys are wrapped // for it. Until that ships, fresh-login-without-backup means old // conversations stay encrypted. // // For NEW conversations: the key is generated at conv-creation time // and includes all current devices of all members, so a freshly-logged- // in device CAN still participate in newly-created conversations. It // just can't read the back-history of conversations it wasn't a member // of when those messages were sealed. // // We deliberately keep the helper functions below (syncAllExistingGaps, // wrapForOneDevice, …) intact so the upcoming approval flow can wire // them to user-driven triggers without rebuilding from scratch. void ownUserId; void ownDeviceId; void backfilledKey; void supabase; return () => {}; } // Keep helpers alive across the auto-sync hibernation window so the // upcoming approval flow can re-wire them. Without this no-op reference // `tsc --noEmit` flags them as unused (TS6133). // // `wrapForOneDevice` and `syncOneConversationGaps` are exported below for // the device-approval module — once the user explicitly approves a new // device the approval flow re-uses these helpers to wrap conv-keys for // that specific user. void (() => { void listMyConversationIds; void listConversationMembers; void listExistingKeyRecipients; void getActiveKeyVersion; void syncAllExistingGaps; void isExpectedShareFailure; void rawFrom; }); 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 listConversationMembers( conversationId: string, ): Promise<{ 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: keys, error: kErr } = await rawFrom('user_keys') .select('user_id, public_key') .in('user_id', userIds); if (kErr) { console.warn('keySync: user-keys lookup failed', kErr); return []; } return (keys ?? []) as { user_id: string; public_key: string }[]; } async function listExistingKeyRecipients( conversationId: string, keyVersion: number, ): Promise> { const { data, error } = await rawFrom('conversation_keys') .select('recipient_user_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_user_id: string }) => r.recipient_user_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 }); } } } export async function syncOneConversationGaps(ctx: SyncCtx, convId: string): Promise { const version = await getActiveKeyVersion(convId); const members = await listConversationMembers(convId); if (members.length === 0) return; const recipients = await listExistingKeyRecipients(convId, version); const ownCtx: OwnUserCtx = { userId: ctx.myUserId, privateKey: ctx.priv, }; for (const m of members) { if (recipients.has(m.user_id)) continue; // Skip our own user — 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 (m.user_id === ctx.myUserId) continue; try { await shareConvKeyToUser(supabase, convId, m.user_id, pgHexToBytes(m.public_key), ownCtx); } catch (err: unknown) { // Backfill is best-effort. Most common silent failures: // - tryGetConvKey couldn't unwrap (another peer will fill the gap). // - RLS rejects because the recipient is a pending (not-yet // accepted) DM member, or was removed from the conv. // Both are recoverable / expected, swallow without spam. if (isExpectedShareFailure(err)) continue; console.warn('keySync: shareConvKeyToUser gap-fill failed', { convId, recipient: m.user_id, err, }); } } } function isExpectedShareFailure(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as { message?: string; code?: string; details?: string; status?: number }; const code = (e.code ?? '').toString(); const status = e.status; const haystack = (e.message ?? '') + ' ' + (e.details ?? ''); return ( status === 403 || code === '42501' || // postgres: insufficient_privilege (RLS) code === '23505' || // unique_violation haystack.includes('row-level security') || haystack.includes('does not have it yet') || haystack.includes('Forbidden') ); } // Approval-flow helper. The legacy signature took (deviceId, userId, // devicePubHex) because conv-keys were wrapped per-device. In the per-user // model only the user dimension matters, so the device-id parameter is // ignored and the public key passed in MUST be the recipient user's // user_keys.public_key (callers will be updated alongside the broader // approval-flow rework). export async function wrapForOneDevice( ctx: SyncCtx, newDeviceId: string, newDeviceUserId: string, newDevicePubHex: string, ): Promise { void newDeviceId; // kept for API compat; no longer used 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: OwnUserCtx = { userId: ctx.myUserId, privateKey: ctx.priv, }; for (const convId of sharedConvs) { try { await shareConvKeyToUser(supabase, convId, newDeviceUserId, newPub, ownCtx); } catch (err: unknown) { console.warn('keySync: shareConvKeyToUser failed', { convId, err }); } } }