import { decryptFrom, encryptFor } from '../crypto/box'; import { bytesToPgHex, pgHexToBytes } from '../supabase/bytea'; import type { AppSupabaseClient } from '../supabase/client'; export interface MigrateParams { client: AppSupabaseClient; ownUserId: string; ownNewPublicKey: Uint8Array; ownNewPrivateKey: Uint8Array; ownLegacyDeviceIds: string[]; ownLegacyDevicePrivateKeys: Record; } export interface MigrateResult { migratedConversations: number; errors: { conversationId: string; reason: string }[]; } interface LegacyRow { conversation_id: string; key_version: number; recipient_device_id: string; sender_device_id: string; sender_user_id: string | null; encrypted_key: string; nonce: string; } function hexNoPrefix(bytes: Uint8Array): string { return bytesToPgHex(bytes).slice(2); } async function fetchSenderPubKeys( client: AppSupabaseClient, ids: string[], ): Promise> { if (ids.length === 0) return new Map(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data, error } = await (client as any) .from('devices').select('id, user_id, public_key').in('id', ids); if (error) throw error; const out = new Map(); for (const row of (data ?? []) as { id: string; user_id: string; public_key: string }[]) { out.set(row.id, { userId: row.user_id, publicKey: pgHexToBytes(row.public_key) }); } return out; } export async function migrateOwnLegacyBundles(params: MigrateParams): Promise { const result: MigrateResult = { migratedConversations: 0, errors: [] }; if (params.ownLegacyDeviceIds.length === 0) return result; // PostgREST translates `.eq('col', null)` to `col=eq.null` which evaluates as // `col = NULL` — always false in SQL. `.is('col', null)` produces `col IS NULL`, // which is what we want here. Wrong filter silently returned zero rows so the // entire migration was a no-op (0.18.0 bug; users could set a PIN but no // conv-key bundles got re-wrapped → "Awaiting key" on every send). // eslint-disable-next-line @typescript-eslint/no-explicit-any const { data: rowsRaw, error } = await (params.client as any) .from('conversation_keys') .select('conversation_id, key_version, recipient_device_id, sender_device_id, sender_user_id, encrypted_key, nonce') .in('recipient_device_id', params.ownLegacyDeviceIds) .is('recipient_user_id', null); if (error) throw error; const rows = (rowsRaw ?? []) as LegacyRow[]; if (rows.length === 0) return result; const senderDeviceIds = Array.from(new Set(rows.map((r) => r.sender_device_id).filter(Boolean))); const senderMap = await fetchSenderPubKeys(params.client, senderDeviceIds); for (const row of rows) { const ownPriv = params.ownLegacyDevicePrivateKeys[row.recipient_device_id]; if (!ownPriv) { result.errors.push({ conversationId: row.conversation_id, reason: 'no legacy private key in store' }); continue; } const sender = senderMap.get(row.sender_device_id); if (!sender) { result.errors.push({ conversationId: row.conversation_id, reason: 'sender device not found' }); continue; } let convKey: Uint8Array; try { convKey = await decryptFrom( pgHexToBytes(row.encrypted_key), pgHexToBytes(row.nonce), sender.publicKey, ownPriv, ); } catch (err) { result.errors.push({ conversationId: row.conversation_id, reason: err instanceof Error ? err.message : String(err), }); continue; } const wrapped = await encryptFor(convKey, params.ownNewPublicKey, params.ownNewPrivateKey); // eslint-disable-next-line @typescript-eslint/no-explicit-any const { error: rpcError } = await (params.client as any).rpc('migrate_user_key_recipients', { p_conv_id: row.conversation_id, p_user_id: params.ownUserId, p_key_version: row.key_version, p_bundles: [{ encrypted_key: hexNoPrefix(wrapped.ciphertext), nonce: hexNoPrefix(wrapped.nonce), sender_user_id: row.sender_user_id ?? params.ownUserId, }], }); if (rpcError) { result.errors.push({ conversationId: row.conversation_id, reason: (rpcError as Error).message ?? String(rpcError), }); continue; } result.migratedConversations += 1; } return result; }