feat(shared): migrate legacy per-device conv-key bundles to per-user
This commit is contained in:
@@ -6,6 +6,7 @@ export * from './convKeys';
|
|||||||
export * from './groups';
|
export * from './groups';
|
||||||
export * from './messages';
|
export * from './messages';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
export * from './userKeyMigration';
|
||||||
|
|
||||||
// ----- RPC wrappers ---------------------------------------------------------
|
// ----- RPC wrappers ---------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof migrateOwnLegacyBundles>[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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Map<string, { userId: string; publicKey: Uint8Array }>> {
|
||||||
|
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<string, { userId: string; publicKey: Uint8Array }>();
|
||||||
|
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<MigrateResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user