diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index 5f8bce2..cb04224 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -6,6 +6,7 @@ export * from './convKeys'; export * from './groups'; export * from './messages'; export * from './types'; +export * from './userKeyMigration'; // ----- RPC wrappers --------------------------------------------------------- diff --git a/packages/shared/src/chat/userKeyMigration.test.ts b/packages/shared/src/chat/userKeyMigration.test.ts new file mode 100644 index 0000000..3a4a3fa --- /dev/null +++ b/packages/shared/src/chat/userKeyMigration.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, beforeAll } from 'vitest'; + +import { setCryptoBackend, getCryptoBackend } from '../crypto/backend'; +import { makeWasmTestBackend } from '../crypto/testBackend'; +import { encryptFor } from '../crypto/box'; +import { migrateOwnLegacyBundles } from './userKeyMigration'; + +beforeAll(async () => { setCryptoBackend(await makeWasmTestBackend()); }); + +describe('migrateOwnLegacyBundles', () => { + it('re-wraps legacy device bundles to user recipients and skips non-own rows', async () => { + const backend = getCryptoBackend(); + const senderKp = backend.generateKeyPair(); + const oldDeviceKp = backend.generateKeyPair(); + const newUserKp = backend.generateKeyPair(); + const otherDeviceKp = backend.generateKeyPair(); + + const convKey = backend.randomBytes(backend.secretboxKeyLength); + const wrappedForOldDevice = await encryptFor(convKey, oldDeviceKp.publicKey, senderKp.privateKey); + const wrappedForOther = await encryptFor(convKey, otherDeviceKp.publicKey, senderKp.privateKey); + + const calls: { name: string; params: unknown }[] = []; + const stubClient = { + from: (table: string) => { + if (table === 'conversation_keys') { + return { + select: () => ({ + in: () => ({ + eq: () => Promise.resolve({ + data: [ + { + conversation_id: 'conv-1', + key_version: 1, + recipient_device_id: 'dev-old', + sender_device_id: 'dev-sender', + sender_user_id: 'sender-user', + encrypted_key: '\\x' + Buffer.from(wrappedForOldDevice.ciphertext).toString('hex'), + nonce: '\\x' + Buffer.from(wrappedForOldDevice.nonce).toString('hex'), + }, + { + conversation_id: 'conv-2', + key_version: 1, + recipient_device_id: 'dev-other', + sender_device_id: 'dev-sender', + sender_user_id: 'sender-user', + encrypted_key: '\\x' + Buffer.from(wrappedForOther.ciphertext).toString('hex'), + nonce: '\\x' + Buffer.from(wrappedForOther.nonce).toString('hex'), + }, + ], + error: null, + }), + }), + }), + }; + } + if (table === 'devices') { + return { + select: () => ({ + in: () => Promise.resolve({ + data: [{ id: 'dev-sender', user_id: 'sender-user', public_key: '\\x' + Buffer.from(senderKp.publicKey).toString('hex') }], + error: null, + }), + }), + }; + } + throw new Error('unexpected table ' + table); + }, + rpc: (name: string, params: unknown) => { + calls.push({ name, params }); + return Promise.resolve({ data: 1, error: null }); + }, + } as unknown as Parameters[0]['client']; + + const result = await migrateOwnLegacyBundles({ + client: stubClient, + ownUserId: 'me-user', + ownNewPublicKey: newUserKp.publicKey, + ownNewPrivateKey: newUserKp.privateKey, + ownLegacyDeviceIds: ['dev-old'], + ownLegacyDevicePrivateKeys: { 'dev-old': oldDeviceKp.privateKey }, + }); + + expect(result.migratedConversations).toBe(1); + expect(calls).toHaveLength(1); + expect(calls[0]!.name).toBe('migrate_user_key_recipients'); + const params = calls[0]!.params as { p_conv_id: string; p_user_id: string }; + expect(params.p_conv_id).toBe('conv-1'); + expect(params.p_user_id).toBe('me-user'); + }); +}); diff --git a/packages/shared/src/chat/userKeyMigration.ts b/packages/shared/src/chat/userKeyMigration.ts new file mode 100644 index 0000000..f153b41 --- /dev/null +++ b/packages/shared/src/chat/userKeyMigration.ts @@ -0,0 +1,111 @@ +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; + + // 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) + .eq('recipient_user_id', null as unknown as string); + 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; +}